@sentry/junior-dashboard 0.266.0 → 0.268.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 (96) hide show
  1. package/dist/app.js +85 -12
  2. package/dist/client.js +94 -64
  3. package/dist/index.js +85 -12
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +11 -5
  6. package/src/app.ts +0 -2
  7. package/src/auth.ts +4 -7
  8. package/src/client/App.tsx +55 -53
  9. package/src/client/components/DetailsPanel.tsx +12 -13
  10. package/src/client/components/Drawer.tsx +46 -23
  11. package/src/client/components/ImageAttachment.tsx +1 -1
  12. package/src/client/components/ParticipantAvatarStack.tsx +6 -2
  13. package/src/client/components/README.md +15 -2
  14. package/src/client/components/UserInputForm.tsx +283 -0
  15. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  16. package/src/client/components/ui/alert.tsx +1 -1
  17. package/src/client/components/ui/button-group.tsx +59 -0
  18. package/src/client/components/ui/button.tsx +11 -1
  19. package/src/client/components/ui/dialog.tsx +29 -35
  20. package/src/client/components/ui/drawer.tsx +140 -0
  21. package/src/client/components/ui/empty.tsx +30 -1
  22. package/src/client/components/ui/separator.tsx +22 -0
  23. package/src/client/components/ui/useSheet.ts +5 -1
  24. package/src/client/conversations/ComposerImages.tsx +66 -14
  25. package/src/client/conversations/ConversationComposer.tsx +84 -12
  26. package/src/client/conversations/ConversationDetails.tsx +3 -27
  27. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  28. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  29. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  30. package/src/client/conversations/ConversationPage.tsx +160 -2
  31. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  32. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  33. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  34. package/src/client/conversations/DetailsList.tsx +33 -0
  35. package/src/client/conversations/EventDetails.tsx +46 -19
  36. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  37. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  38. package/src/client/conversations/README.md +30 -13
  39. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  40. package/src/client/conversations/TranscriptActivityGroup.tsx +27 -19
  41. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  42. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  43. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  44. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  45. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  46. package/src/client/conversations/TranscriptMessageView.tsx +10 -1
  47. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  48. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  49. package/src/client/conversations/TranscriptTurnContextView.tsx +174 -161
  50. package/src/client/conversations/TranscriptView.tsx +7 -3
  51. package/src/client/conversations/UserInputRequestCard.tsx +39 -0
  52. package/src/client/conversations/conversationOutbox.ts +46 -0
  53. package/src/client/conversations/eventTime.ts +13 -1
  54. package/src/client/conversations/eventTranscript.ts +12 -5
  55. package/src/client/conversations/queries.ts +127 -26
  56. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  57. package/src/client/conversations/turnContext.ts +15 -0
  58. package/src/client/conversations/userInputRequest.ts +56 -0
  59. package/src/client/format.ts +31 -8
  60. package/src/client/pages/SettingsPage.tsx +103 -3
  61. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  62. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  63. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  64. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  65. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  66. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  67. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  68. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  69. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  70. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  71. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  72. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  73. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  74. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  75. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  76. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  77. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  78. package/src/client/pages/automations/README.md +56 -46
  79. package/src/client/pages/code/RepositorySections.tsx +1 -0
  80. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  81. package/src/client/primaryNav.ts +15 -2
  82. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  83. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  84. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  85. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  86. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  87. package/src/client/review-desk/queries.ts +164 -0
  88. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  89. package/src/client/types.ts +3 -0
  90. package/src/mock-reporting/fixtures.ts +54 -2
  91. package/src/mock-reporting/routes.ts +20 -0
  92. package/src/shell.ts +3 -0
  93. package/src/client/conversations/transcriptStyles.ts +0 -10
  94. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  95. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  96. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -1,331 +1,11 @@
1
1
  export const TRANSCRIPT_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
- export type TranscriptMarkdownLink = {
5
- end: number;
6
- href: string;
7
- label: string;
8
- start: number;
9
- };
10
-
11
- type TextRange = {
12
- end: number;
13
- start: number;
14
- };
15
-
16
- /** Find safe markdown and bare links that should become transcript anchors. */
17
- export function findTranscriptMarkdownLinks(
18
- text: string,
19
- ): TranscriptMarkdownLink[] {
20
- const literalRanges = findLiteralRanges(text);
21
- const markdownSyntaxRanges = findMarkdownLinkSyntaxRanges(
22
- text,
23
- literalRanges,
24
- );
25
- const markdownLinks = findMarkdownLinks(text, literalRanges);
26
- const autolinks = findCommonMarkAutolinks(
27
- text,
28
- mergeRanges([...literalRanges, ...markdownSyntaxRanges]),
29
- );
30
- const bareRanges = mergeRanges([
31
- ...literalRanges,
32
- ...markdownSyntaxRanges,
33
- ...autolinks,
34
- ]);
35
- return [
36
- ...markdownLinks,
37
- ...autolinks,
38
- ...findBareLinks(text, bareRanges),
39
- ].sort((left, right) => left.start - right.start || left.end - right.end);
40
- }
41
-
42
- function findMarkdownLinks(
43
- text: string,
44
- ignoredRanges: TextRange[],
45
- ): TranscriptMarkdownLink[] {
46
- const links: TranscriptMarkdownLink[] = [];
47
- let cursor = 0;
48
- while (cursor < text.length) {
49
- const link = findNextMarkdownLink(text, cursor, ignoredRanges);
50
- if (!link) break;
51
- links.push(link);
52
- cursor = link.end;
53
- }
54
- return links;
55
- }
56
-
57
- function findNextMarkdownLink(
58
- text: string,
59
- start: number,
60
- ignoredRanges: TextRange[],
61
- ): TranscriptMarkdownLink | undefined {
62
- for (
63
- let linkStart = text.indexOf("[", start);
64
- linkStart >= 0;
65
- linkStart = text.indexOf("[", linkStart + 1)
66
- ) {
67
- if (isInRange(linkStart, ignoredRanges) || isEscaped(text, linkStart)) {
68
- continue;
69
- }
70
-
71
- const labelEnd = text.indexOf("]", linkStart + 1);
72
- if (labelEnd < 0) return undefined;
73
- if (text[labelEnd + 1] !== "(") continue;
74
-
75
- const label = text.slice(linkStart + 1, labelEnd);
76
- if (!isMarkdownLinkLabel(label)) continue;
77
-
78
- const destination = readMarkdownDestination(text, labelEnd + 2);
79
- if (!destination) continue;
80
-
81
- const href = safeMarkdownDestinationHref(destination.href);
82
- if (!href) continue;
83
-
84
- return {
85
- end: destination.end,
86
- href,
87
- label,
88
- start: linkStart,
89
- };
90
- }
91
- return undefined;
92
- }
93
-
94
- function findMarkdownLinkSyntaxRanges(
95
- text: string,
96
- ignoredRanges: TextRange[],
97
- ): TextRange[] {
98
- const ranges: TextRange[] = [];
99
- for (
100
- let linkStart = text.indexOf("[");
101
- linkStart >= 0;
102
- linkStart = text.indexOf("[", linkStart + 1)
103
- ) {
104
- if (isInRange(linkStart, ignoredRanges) || isEscaped(text, linkStart)) {
105
- continue;
106
- }
107
-
108
- const labelEnd = text.indexOf("]", linkStart + 1);
109
- if (labelEnd < 0) continue;
110
- if (text[labelEnd + 1] !== "(") continue;
111
-
112
- const label = text.slice(linkStart + 1, labelEnd);
113
- if (!isMarkdownLinkLabel(label)) continue;
114
-
115
- const destination = readMarkdownDestination(text, labelEnd + 2);
116
- if (!destination) continue;
117
-
118
- ranges.push({ start: linkStart, end: destination.end });
119
- }
120
- return ranges;
121
- }
122
-
123
- function isMarkdownLinkLabel(label: string): boolean {
124
- return Boolean(label) && !label.includes("\n") && !label.includes("[");
125
- }
126
-
127
- function readMarkdownDestination(
128
- text: string,
129
- start: number,
130
- ): { end: number; href: string } | undefined {
131
- let depth = 0;
132
- for (let index = start; index < text.length; index += 1) {
133
- const char = text[index];
134
- if (char === "\\") {
135
- index += 1;
136
- continue;
137
- }
138
- if (char === "(") {
139
- depth += 1;
140
- continue;
141
- }
142
- if (char !== ")") continue;
143
-
144
- if (depth > 0) {
145
- depth -= 1;
146
- continue;
147
- }
148
-
149
- const href = text.slice(start, index);
150
- return href ? { end: index + 1, href } : undefined;
151
- }
152
- return undefined;
153
- }
154
-
155
- function findCommonMarkAutolinks(
156
- text: string,
157
- ignoredRanges: TextRange[],
158
- ): TranscriptMarkdownLink[] {
159
- const links: TranscriptMarkdownLink[] = [];
160
- const pattern = /<(https?:\/\/[^<>\s]+|mailto:[^<>\s]+)>/gi;
161
- let match: RegExpExecArray | null;
162
-
163
- while ((match = pattern.exec(text))) {
164
- if (isInRange(match.index, ignoredRanges) || isEscaped(text, match.index)) {
165
- continue;
166
- }
167
- const href = safeLinkHref(match[1] ?? "");
168
- if (!href) continue;
169
- links.push({
170
- end: match.index + match[0].length,
171
- href,
172
- label: href,
173
- start: match.index,
174
- });
175
- }
176
- return links;
177
- }
178
-
179
- function findNextBareLink(
180
- text: string,
181
- start: number,
182
- ignoredRanges: TextRange[],
183
- ): TranscriptMarkdownLink | undefined {
184
- // Stop at * so emphasis markers stay outside the URL, matching Slack outbound
185
- // bare-URL wrapping (`readBareUrl` in packages/junior/src/chat/slack/mrkdwn.ts).
186
- const bareUrlPattern = /(https?:\/\/[^\s<>"'*]+|mailto:[^\s<>"'*]+)/gi;
187
- bareUrlPattern.lastIndex = start;
188
-
189
- let match: RegExpExecArray | null;
190
- while ((match = bareUrlPattern.exec(text))) {
191
- if (isInRange(match.index, ignoredRanges)) continue;
192
-
193
- const bareLink = trimBareUrl(match[0]);
194
- const href = safeLinkHref(bareLink);
195
- if (!href) continue;
196
-
197
- return {
198
- end: match.index + bareLink.length,
199
- href,
200
- label: href,
201
- start: match.index,
202
- };
203
- }
204
- return undefined;
205
- }
206
-
207
- function findBareLinks(
208
- text: string,
209
- ignoredRanges: TextRange[],
210
- ): TranscriptMarkdownLink[] {
211
- const links: TranscriptMarkdownLink[] = [];
212
- let cursor = 0;
213
- while (cursor < text.length) {
214
- const link = findNextBareLink(text, cursor, ignoredRanges);
215
- if (!link) break;
216
- links.push(link);
217
- cursor = link.end;
218
- }
219
- return links;
220
- }
221
-
222
- function findLiteralRanges(text: string): TextRange[] {
223
- return [...findInlineCodeRanges(text), ...findEscapedMarkdownLinkRanges(text)]
224
- .filter((range) => range.end > range.start)
225
- .sort((left, right) => left.start - right.start || left.end - right.end);
226
- }
227
-
228
- function findInlineCodeRanges(text: string): TextRange[] {
229
- const ranges: TextRange[] = [];
230
- for (let index = 0; index < text.length; index += 1) {
231
- if (text[index] !== "`" || isEscaped(text, index)) continue;
232
-
233
- const ticks = countRun(text, index, "`");
234
- const closing = findClosingBacktickRun(text, index + ticks, ticks);
235
- if (closing < 0) {
236
- index += ticks - 1;
237
- continue;
238
- }
239
-
240
- ranges.push({ start: index, end: closing + ticks });
241
- index = closing + ticks - 1;
242
- }
243
- return ranges;
244
- }
245
-
246
- function findClosingBacktickRun(
247
- text: string,
248
- start: number,
249
- ticks: number,
250
- ): number {
251
- for (let index = start; index < text.length; index += 1) {
252
- if (text[index] !== "`" || isEscaped(text, index)) continue;
253
-
254
- const run = countRun(text, index, "`");
255
- if (run === ticks) return index;
256
- index += run - 1;
257
- }
258
- return -1;
259
- }
260
-
261
- function findEscapedMarkdownLinkRanges(text: string): TextRange[] {
262
- const ranges: TextRange[] = [];
263
- for (
264
- let linkStart = text.indexOf("[");
265
- linkStart >= 0;
266
- linkStart = text.indexOf("[", linkStart + 1)
267
- ) {
268
- if (!isEscaped(text, linkStart)) continue;
269
-
270
- const labelEnd = text.indexOf("]", linkStart + 1);
271
- if (labelEnd < 0) continue;
272
- if (text[labelEnd + 1] !== "(") continue;
273
-
274
- const destination = readMarkdownDestination(text, labelEnd + 2);
275
- if (!destination) continue;
276
-
277
- ranges.push({ start: linkStart, end: destination.end });
278
- }
279
- return ranges;
280
- }
281
-
282
- function isInRange(index: number, ranges: TextRange[]): boolean {
283
- for (const range of ranges) {
284
- if (index < range.start) return false;
285
- if (index >= range.start && index < range.end) return true;
286
- }
287
- return false;
288
- }
289
-
290
- function isEscaped(text: string, index: number): boolean {
291
- let backslashes = 0;
292
- for (
293
- let cursor = index - 1;
294
- cursor >= 0 && text[cursor] === "\\";
295
- cursor -= 1
296
- ) {
297
- backslashes += 1;
298
- }
299
- return backslashes % 2 === 1;
300
- }
301
-
302
- function countRun(text: string, start: number, char: string): number {
303
- let count = 0;
304
- for (
305
- let index = start;
306
- index < text.length && text[index] === char;
307
- index += 1
308
- ) {
309
- count += 1;
310
- }
311
- return count;
312
- }
313
-
314
- function safeMarkdownDestinationHref(destination: string): string | undefined {
315
- const href = readMarkdownHref(destination);
316
- return href ? safeLinkHref(href) : undefined;
317
- }
318
-
319
- function readMarkdownHref(destination: string): string | undefined {
320
- const trimmed = destination.trim();
321
- const match =
322
- /^(<[^>\n]+>|[^\s]+)(?:\s+(?:"[^"]*"|'[^']*'|\([^)]*\)))?$/.exec(trimmed);
323
- const href = match?.[1];
324
- if (!href) return undefined;
325
- return href.startsWith("<") && href.endsWith(">") ? href.slice(1, -1) : href;
326
- }
327
-
328
- function safeLinkHref(href: string): string | undefined {
4
+ /**
5
+ * Keep a transcript link destination only when it is an absolute `http:`,
6
+ * `https:`, or `mailto:` URL. Return undefined to drop the link.
7
+ */
8
+ export function safeTranscriptHref(href: string): string | undefined {
329
9
  const trimmed = href.trim();
330
10
  const hasControlCharacter = [...trimmed].some((character) => {
331
11
  const code = character.charCodeAt(0);
@@ -343,45 +23,3 @@ function safeLinkHref(href: string): string | undefined {
343
23
  return undefined;
344
24
  }
345
25
  }
346
-
347
- function trimBareUrl(href: string): string {
348
- let trimmed = href;
349
-
350
- while (shouldTrimBareUrlSuffix(trimmed)) {
351
- trimmed = trimmed.slice(0, -1);
352
- }
353
-
354
- return trimmed;
355
- }
356
-
357
- function shouldTrimBareUrlSuffix(href: string): boolean {
358
- const last = href.at(-1);
359
- if (!last) return false;
360
- if (/[.,;:!?]/.test(last)) return true;
361
- return last === ")" && closingParensExceedOpening(href);
362
- }
363
-
364
- function closingParensExceedOpening(value: string): boolean {
365
- let balance = 0;
366
- for (const char of value) {
367
- if (char === "(") balance += 1;
368
- if (char === ")") balance -= 1;
369
- }
370
- return balance < 0;
371
- }
372
-
373
- function mergeRanges(ranges: TextRange[]): TextRange[] {
374
- const sorted = ranges
375
- .filter((range) => range.end > range.start)
376
- .sort((left, right) => left.start - right.start || left.end - right.end);
377
- const merged: TextRange[] = [];
378
- for (const range of sorted) {
379
- const previous = merged.at(-1);
380
- if (!previous || range.start > previous.end) {
381
- merged.push({ ...range });
382
- continue;
383
- }
384
- previous.end = Math.max(previous.end, range.end);
385
- }
386
- return merged;
387
- }
@@ -33,3 +33,18 @@ export function memoryRecallContent(context: TranscriptViewTurnContext) {
33
33
  const parsed = memoryRecallContentSchema.safeParse(context.content);
34
34
  return parsed.success ? parsed.data : undefined;
35
35
  }
36
+
37
+ const userCustomizationContentSchema = z.object({ text: z.string() }).strict();
38
+
39
+ /** Parse the saved user customization that the runtime added to one Turn. */
40
+ export function userCustomizationContent(context: TranscriptViewTurnContext) {
41
+ if (
42
+ context.pluginName !== "junior" ||
43
+ context.kind !== "user_customization" ||
44
+ context.version !== 1
45
+ ) {
46
+ return undefined;
47
+ }
48
+ const parsed = userCustomizationContentSchema.safeParse(context.content);
49
+ return parsed.success ? parsed.data : undefined;
50
+ }
@@ -0,0 +1,56 @@
1
+ import {
2
+ userInputRequestSchema,
3
+ type ConversationReportEvent,
4
+ type UserInputRequest,
5
+ } from "@sentry/junior/api/schema";
6
+
7
+ /** Model-facing name of Junior's tool that asks a person for input. */
8
+ const REQUEST_USER_INPUT_TOOL_NAME = "requestUserInput";
9
+
10
+ /** A request from Junior that still waits for an answer. */
11
+ export type OpenUserInputRequest = UserInputRequest & { toolCallId: string };
12
+
13
+ /**
14
+ * Find the latest `requestUserInput` call that no user message answers yet.
15
+ * Any later user message is the answer, whether it is a submitted form or
16
+ * typed text.
17
+ *
18
+ * Reporting splits one call over events: the assistant event has the input
19
+ * and status `running`, and the later result event has status `completed`
20
+ * without the input. Keep the input by tool call id until the result.
21
+ */
22
+ export function openUserInputRequest(
23
+ events: readonly ConversationReportEvent[] | undefined,
24
+ ): OpenUserInputRequest | undefined {
25
+ const inputs = new Map<string, unknown>();
26
+ const completed = new Set<string>();
27
+ let open: OpenUserInputRequest | undefined;
28
+ for (const event of events ?? []) {
29
+ const data = event.data;
30
+ if (data.type === "message" && data.role === "user") {
31
+ open = undefined;
32
+ continue;
33
+ }
34
+ if (data.type !== "tool_calls") continue;
35
+ for (const call of data.calls) {
36
+ if (call.name !== REQUEST_USER_INPUT_TOOL_NAME) continue;
37
+ if (call.input !== undefined) inputs.set(call.toolCallId, call.input);
38
+ if (call.status === "error" && open?.toolCallId === call.toolCallId) {
39
+ open = undefined;
40
+ }
41
+ // Open a request once, when its result first arrives. A repeated
42
+ // result must not reopen a request that a user message answered.
43
+ if (call.status !== "completed" || completed.has(call.toolCallId)) {
44
+ continue;
45
+ }
46
+ completed.add(call.toolCallId);
47
+ const request = userInputRequestSchema.safeParse(
48
+ inputs.get(call.toolCallId),
49
+ );
50
+ if (request.success) {
51
+ open = { ...request.data, toolCallId: call.toolCallId };
52
+ }
53
+ }
54
+ }
55
+ return open;
56
+ }
@@ -360,6 +360,21 @@ export type MessageSummary = {
360
360
  total: number;
361
361
  };
362
362
 
363
+ /**
364
+ * Return the person who wrote one transcript user message. Child agent input
365
+ * has no person. Other messages fall back to the conversation actor.
366
+ */
367
+ export function transcriptMessageActor(
368
+ conversation: ConversationTranscript,
369
+ message: TranscriptViewMessage,
370
+ ): ActorIdentity | undefined {
371
+ if (transcriptRoleKind(message.role) !== "user") return undefined;
372
+ if (message.agentInvocationId) return undefined;
373
+ if (actorLabel(message.actorIdentity)) return message.actorIdentity;
374
+ if (actorLabel(conversation.actorIdentity)) return conversation.actorIdentity;
375
+ return undefined;
376
+ }
377
+
363
378
  /** Return the best available actor label for one transcript message. */
364
379
  export function transcriptMessageActorLabel(
365
380
  conversation: ConversationTranscript,
@@ -371,11 +386,7 @@ export function transcriptMessageActorLabel(
371
386
  // The conversation actor started the child agent but did not write its
372
387
  // input, so do not fall back to that actor here.
373
388
  if (message.agentInvocationId) return "Agent invocation";
374
- return (
375
- actorLabel(message.actorIdentity) ??
376
- actorLabel(conversation.actorIdentity) ??
377
- "User"
378
- );
389
+ return actorLabel(transcriptMessageActor(conversation, message)) ?? "User";
379
390
  }
380
391
  if (kind === "system") return "System";
381
392
  if (kind === "tool") return "Tool";
@@ -786,6 +797,11 @@ export function peoplePath(email: string): string {
786
797
  return `/people/${encodeURIComponent(email)}`;
787
798
  }
788
799
 
800
+ /** Build the dashboard route for one memory's details. */
801
+ export function memoryPath(memoryId: string): string {
802
+ return `/memories/${encodeURIComponent(memoryId)}`;
803
+ }
804
+
789
805
  /** Build the canonical detail route for a persisted public location. */
790
806
  export function locationPath(locationId: string): string {
791
807
  return `/system/locations/${encodeURIComponent(locationId)}`;
@@ -796,9 +812,16 @@ export function workspacePath(workspaceId: string): string {
796
812
  return `/system/workspaces/${encodeURIComponent(workspaceId)}`;
797
813
  }
798
814
 
799
- /** Build the canonical automation detail route for an automation id. */
800
- export function automationPath(automationId: string): string {
801
- return `/automations/${encodeURIComponent(automationId)}`;
815
+ /** Automation page tabs. Overview is the bare Automation path. */
816
+ export type AutomationTab = "overview" | "runs" | "versions" | "edit";
817
+
818
+ /** Build the route for one Automation page tab, or its edit mode. */
819
+ export function automationPath(
820
+ automationId: string,
821
+ tab: AutomationTab = "overview",
822
+ ): string {
823
+ const base = `/automations/${encodeURIComponent(automationId)}`;
824
+ return tab === "overview" ? base : `${base}/${tab}`;
802
825
  }
803
826
 
804
827
  function normalizeLanguage(language: string | undefined): BundledLanguage {
@@ -1,21 +1,28 @@
1
- import { useMutation, useQueryClient } from "@tanstack/react-query";
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import { useEffect, useState, type FormEvent } from "react";
3
3
 
4
+ import {
5
+ USER_CUSTOMIZATION_MAX_CHARS,
6
+ userCustomizationSchema,
7
+ } from "@sentry/junior/api/schema";
4
8
  import {
5
9
  dashboardIdentitySchema,
6
10
  type DashboardIdentity,
7
11
  } from "../../api/schema";
8
12
  import { Button } from "../components/ui/button";
9
13
  import { Input } from "../components/ui/input";
14
+ import { Textarea } from "../components/ui/textarea";
10
15
  import { Field, FieldDescription, FieldLabel } from "../components/ui/field";
11
16
  import { InlineError } from "../components/InlineError";
12
17
  import { Card } from "../components/layout/Card";
13
18
  import { useDocumentTitle } from "../documentTitle";
14
- import { patch } from "../http";
19
+ import { DashboardApiError, fetchDashboardJson, patch, put } from "../http";
15
20
  import { dashboardContainerClass } from "../styles";
16
21
  import type { DashboardCoreData } from "../types";
17
22
 
18
23
  const dashboardCoreQueryKey = ["dashboard", "core"] as const;
24
+ const userCustomizationQueryKey = ["dashboard", "user-customization"] as const;
25
+ const USER_CUSTOMIZATION_PATH = "/api/user-customization";
19
26
 
20
27
  type SettingsPageProps = {
21
28
  identity: DashboardIdentity;
@@ -58,7 +65,7 @@ export function SettingsPage({ identity }: SettingsPageProps) {
58
65
  <section className="mx-auto w-full max-w-3xl">
59
66
  <h1 className="m-0 text-2xl font-bold">Settings</h1>
60
67
  <p className="mt-2 mb-0 max-w-2xl text-sm text-dashboard-text-muted">
61
- Manage how your account appears in the dashboard.
68
+ Manage your profile and preferences.
62
69
  </p>
63
70
 
64
71
  <form className="mt-6" onSubmit={submit}>
@@ -97,7 +104,100 @@ export function SettingsPage({ identity }: SettingsPageProps) {
97
104
  </div>
98
105
  </Card>
99
106
  </form>
107
+ <CustomizationForm />
100
108
  </section>
101
109
  </div>
102
110
  );
103
111
  }
112
+
113
+ /**
114
+ * Let the user save text that changes how the agent replies to them. The
115
+ * server returns 404 when the app does not enable this experiment.
116
+ */
117
+ function CustomizationForm() {
118
+ const queryClient = useQueryClient();
119
+ const [text, setText] = useState("");
120
+ const customizationQuery = useQuery({
121
+ queryKey: userCustomizationQueryKey,
122
+ queryFn: ({ signal }) =>
123
+ fetchDashboardJson(
124
+ userCustomizationSchema,
125
+ USER_CUSTOMIZATION_PATH,
126
+ signal,
127
+ ),
128
+ retry: false,
129
+ });
130
+ const savedText = customizationQuery.data?.customization;
131
+ useEffect(() => {
132
+ if (savedText !== undefined) setText(savedText);
133
+ }, [savedText]);
134
+ const save = useMutation({
135
+ mutationFn: (customization: string) =>
136
+ put(userCustomizationSchema, USER_CUSTOMIZATION_PATH, { customization }),
137
+ onSuccess: (updated) => {
138
+ queryClient.setQueryData(userCustomizationQueryKey, updated);
139
+ },
140
+ });
141
+ if (
142
+ customizationQuery.error instanceof DashboardApiError &&
143
+ customizationQuery.error.status === 404
144
+ ) {
145
+ return null;
146
+ }
147
+ if (savedText === undefined) {
148
+ return customizationQuery.isError ? (
149
+ <InlineError className="mt-6">
150
+ Could not load your customization.
151
+ </InlineError>
152
+ ) : null;
153
+ }
154
+ const canSave =
155
+ text.trim() !== savedText &&
156
+ text.trim().length <= USER_CUSTOMIZATION_MAX_CHARS &&
157
+ !save.isPending;
158
+
159
+ function submit(event: FormEvent<HTMLFormElement>) {
160
+ event.preventDefault();
161
+ if (canSave) save.mutate(text.trim());
162
+ }
163
+
164
+ return (
165
+ <form className="mt-6" onSubmit={submit}>
166
+ <Card className="mb-0" padding="md" variant="raised">
167
+ <h2 className="m-0 text-lg font-bold">Customization</h2>
168
+ <Field className="mt-5">
169
+ <FieldLabel htmlFor="user-customization">
170
+ How should replies to you change?
171
+ </FieldLabel>
172
+ <Textarea
173
+ id="user-customization"
174
+ maxLength={USER_CUSTOMIZATION_MAX_CHARS}
175
+ onChange={(event) => {
176
+ setText(event.target.value);
177
+ if (save.isError || save.isSuccess) save.reset();
178
+ }}
179
+ placeholder="Keep replies short and simple."
180
+ value={text}
181
+ />
182
+ <FieldDescription>
183
+ Added to each message you send. Do not put secrets here. (
184
+ {text.trim().length}/{USER_CUSTOMIZATION_MAX_CHARS})
185
+ </FieldDescription>
186
+ </Field>
187
+ <div className="mt-5 flex items-center gap-3">
188
+ <Button disabled={!canSave} type="submit">
189
+ {save.isPending ? "Saving…" : "Save customization"}
190
+ </Button>
191
+ {save.isSuccess ? (
192
+ <p className="m-0 text-sm text-emerald-300">Changes saved.</p>
193
+ ) : null}
194
+ {save.isError ? (
195
+ <InlineError>
196
+ Could not save your customization. Try again.
197
+ </InlineError>
198
+ ) : null}
199
+ </div>
200
+ </Card>
201
+ </form>
202
+ );
203
+ }