@sentry/junior-dashboard 0.268.0 → 0.270.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +7 -105
  2. package/dist/app.js +12 -9
  3. package/dist/client.js +81 -81
  4. package/dist/index.js +12 -9
  5. package/dist/tailwind.css +1 -1
  6. package/package.json +5 -4
  7. package/src/api/schema.ts +2 -1
  8. package/src/app.ts +11 -7
  9. package/src/client/App.tsx +15 -9
  10. package/src/client/api.ts +4 -19
  11. package/src/client/components/AGENTS.md +65 -0
  12. package/src/client/components/ActorAvatar.tsx +1 -1
  13. package/src/client/components/AutomationCard.tsx +2 -1
  14. package/src/client/components/ParticipantAvatarStack.tsx +1 -0
  15. package/src/client/components/ProfileMenu.tsx +88 -29
  16. package/src/client/components/UserInputForm.tsx +110 -37
  17. package/src/client/components/charts/AGENTS.md +15 -0
  18. package/src/client/conversations/AGENTS.md +38 -0
  19. package/src/client/conversations/ConversationBrief.tsx +55 -36
  20. package/src/client/conversations/ConversationComposer.tsx +27 -8
  21. package/src/client/conversations/ConversationEventLog.tsx +1 -0
  22. package/src/client/conversations/ConversationHomeList.tsx +43 -26
  23. package/src/client/conversations/ConversationPage.tsx +90 -56
  24. package/src/client/conversations/ConversationSectionHeading.tsx +44 -0
  25. package/src/client/conversations/ConversationSidebar.tsx +55 -25
  26. package/src/client/conversations/ConversationTranscript.tsx +3 -2
  27. package/src/client/conversations/ConversationWatches.tsx +4 -1
  28. package/src/client/conversations/ConversationWorkspace.tsx +43 -25
  29. package/src/client/conversations/EventDetails.tsx +6 -9
  30. package/src/client/conversations/PendingMailboxStack.tsx +5 -2
  31. package/src/client/conversations/TranscriptContextEventView.tsx +3 -4
  32. package/src/client/conversations/TranscriptEventView.tsx +9 -3
  33. package/src/client/conversations/TranscriptMessageView.tsx +6 -5
  34. package/src/client/conversations/TranscriptReasoningView.tsx +4 -5
  35. package/src/client/conversations/TranscriptRows.tsx +6 -1
  36. package/src/client/conversations/TranscriptStructuredEventView.tsx +5 -4
  37. package/src/client/conversations/TranscriptToolView.tsx +2 -1
  38. package/src/client/conversations/TranscriptView.tsx +6 -6
  39. package/src/client/conversations/conversationOutbox.ts +13 -3
  40. package/src/client/conversations/conversationSections.ts +36 -8
  41. package/src/client/conversations/eventTranscript.ts +1 -0
  42. package/src/client/conversations/transcriptBottomPinning.ts +171 -81
  43. package/src/client/conversations/transcriptScrollAnchor.ts +101 -0
  44. package/src/client/conversations/useCollapsedConversationSections.ts +48 -0
  45. package/src/client/display-text/DisplayText.tsx +68 -0
  46. package/src/client/display-text/emoji.ts +54 -0
  47. package/src/client/{conversations/transcriptMarkdownLinks.ts → display-text/links.ts} +16 -3
  48. package/src/client/{conversations/TranscriptMarkdown.tsx → display-text/markdown.tsx} +83 -19
  49. package/src/client/display-text/mentions.tsx +148 -0
  50. package/src/client/display-text/plainText.ts +29 -0
  51. package/src/client/format.ts +7 -23
  52. package/src/client/pages/automations/AGENTS.md +34 -0
  53. package/src/client/pages/automations/AutomationActions.tsx +18 -20
  54. package/src/client/pages/automations/AutomationEditor.tsx +3 -4
  55. package/src/client/pages/automations/AutomationFilters.tsx +6 -6
  56. package/src/client/pages/automations/AutomationOverview.tsx +5 -5
  57. package/src/client/pages/automations/AutomationStatus.tsx +2 -2
  58. package/src/client/pages/automations/AutomationVersionsTab.tsx +12 -8
  59. package/src/client/pages/code/RepositoryTabs.tsx +6 -1
  60. package/src/client/pages/memory/MemoryDetails.tsx +5 -13
  61. package/src/client/pages/system/AGENTS.md +12 -0
  62. package/src/client/review-desk/ConversationReviewBar.tsx +14 -9
  63. package/src/client/review-desk/ReviewDeskPage.tsx +99 -79
  64. package/src/client/review-desk/ReviewItemActions.tsx +6 -2
  65. package/src/client/review-desk/ReviewItemCard.tsx +58 -17
  66. package/src/client/review-desk/ReviewItemDetail.tsx +17 -9
  67. package/src/client/review-desk/UnsentAnswers.tsx +148 -0
  68. package/src/client/review-desk/queries.ts +25 -16
  69. package/src/client/review-desk/reviewItemFormat.ts +29 -1
  70. package/src/mock-reporting/routes.ts +1 -1
  71. package/src/tailwind.css +1 -1
  72. package/src/client/components/README.md +0 -219
  73. package/src/client/components/charts/README.md +0 -38
  74. package/src/client/conversations/README.md +0 -159
  75. package/src/client/conversations/TranscriptText.tsx +0 -47
  76. package/src/client/conversations/UserInputRequestCard.tsx +0 -39
  77. package/src/client/conversations/conversationActivityPreview.ts +0 -16
  78. package/src/client/conversations/transcriptMentions.tsx +0 -134
  79. package/src/client/conversations/userInputRequest.ts +0 -56
  80. package/src/client/pages/automations/README.md +0 -97
  81. package/src/client/pages/system/README.md +0 -23
@@ -1,11 +1,11 @@
1
- export const TRANSCRIPT_ANCHOR_CLASS =
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 transcript link destination only when it is an absolute `http:`,
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 safeTranscriptHref(href: string): string | undefined {
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
- safeTranscriptHref,
17
- TRANSCRIPT_ANCHOR_CLASS,
18
- } from "./transcriptMarkdownLinks";
19
- import { renderMentionText } from "./transcriptMentions";
20
- import { HighlightText, useTranscriptSearch } from "./transcriptSearch";
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 transcript markdown as readable prose.
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 and bold-only section labels, so
27
- * those render as lists and headings too.
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 TranscriptMarkdown(props: {
30
- compact?: boolean;
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.compact
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.compact ? "gap-3" : "gap-2")}>
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={safeTranscriptHref}
69
+ urlTransform={safeHref}
54
70
  >
55
- {normalizeSlackBullets(props.text)}
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 transcript search matches.
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" ? renderMentionText(child, `t-${index}`) : child,
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
- // `safeTranscriptHref` removes unsafe destinations. Keep their label text.
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={TRANSCRIPT_ANCHOR_CLASS}
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
+ }
@@ -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
- /** Detect the viewer's own local timezone as the default display timezone. */
23
- function browserTimeZone(): string {
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 configured display timezone. */
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: displayTimeZone(),
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: displayTimeZone(),
153
+ timeZone: getDashboardTimeZone(),
170
154
  })
171
155
  : date.toLocaleTimeString(undefined, {
172
- timeZone: displayTimeZone(),
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: displayTimeZone(),
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 = automation.ownedByViewer && !completed;
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
- {automation.ownedByViewer ? (
139
- <Tooltip>
140
- <TooltipTrigger
141
- render={
142
- <Button
143
- focusableWhenDisabled
144
- aria-label="Delete automation"
145
- size="icon"
146
- variant="danger"
147
- onClick={(event) => onDelete(event.currentTarget)}
148
- disabled={mutation.isPending}
149
- >
150
- <Trash2 aria-hidden="true" size={16} />
151
- </Button>
152
- }
153
- />
154
- <TooltipContent size="compact">Delete automation</TooltipContent>
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 their
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
- text: filters.type === "scheduled" ? "Scheduled" : "Event",
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
- text:
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
- text:
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
- text: stateOptions.find((option) => option.value === filters.state)
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.text}`}
166
+ title={`${filter.label}: ${filter.valueLabel}`}
167
167
  onClick={() => onChange(filter.key, "")}
168
168
  >
169
169
  <span className="truncate">
170
- {filter.label}: {filter.text}
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 { TranscriptText } from "../../conversations/TranscriptText";
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
- <TranscriptText
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 its settings because it is in a public channel. "}
201
- Only {automation.createdBy} can pause, resume, or delete it, change
202
- where results go, or turn on their connected accounts.
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
- ? `Ask ${automation.createdBy} to resume after this is fixed.`
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}