@sentry/junior-dashboard 0.265.0 → 0.267.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 (104) hide show
  1. package/dist/app.js +49 -12
  2. package/dist/client.js +94 -63
  3. package/dist/index.js +49 -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 +35 -53
  9. package/src/client/components/ActorAvatar.tsx +10 -22
  10. package/src/client/components/ConnectionBanner.tsx +4 -3
  11. package/src/client/components/CopyButton.tsx +3 -4
  12. package/src/client/components/DetailsPanel.tsx +12 -13
  13. package/src/client/components/Drawer.tsx +46 -23
  14. package/src/client/components/EmptyTelemetry.tsx +5 -3
  15. package/src/client/components/ImageAttachment.tsx +49 -63
  16. package/src/client/components/InlineError.tsx +8 -4
  17. package/src/client/components/Notice.tsx +19 -26
  18. package/src/client/components/Pagination.tsx +26 -26
  19. package/src/client/components/ParticipantAvatarStack.tsx +13 -10
  20. package/src/client/components/README.md +28 -3
  21. package/src/client/components/SearchInput.tsx +51 -41
  22. package/src/client/components/VersionDriftBanner.tsx +9 -7
  23. package/src/client/components/charts/CacheWrites.tsx +52 -43
  24. package/src/client/components/ui/alert.tsx +64 -0
  25. package/src/client/components/ui/avatar.tsx +53 -0
  26. package/src/client/components/ui/button-group.tsx +59 -0
  27. package/src/client/components/ui/button.tsx +2 -1
  28. package/src/client/components/ui/dialog.tsx +60 -0
  29. package/src/client/components/ui/drawer.tsx +140 -0
  30. package/src/client/components/ui/empty.tsx +61 -0
  31. package/src/client/components/ui/input-group.tsx +96 -0
  32. package/src/client/components/ui/pagination.tsx +60 -0
  33. package/src/client/components/ui/separator.tsx +22 -0
  34. package/src/client/components/ui/spinner.tsx +21 -0
  35. package/src/client/components/ui/table.tsx +22 -1
  36. package/src/client/components/ui/tooltip.tsx +1 -1
  37. package/src/client/components/ui/useSheet.ts +5 -1
  38. package/src/client/conversations/ComposerImages.tsx +66 -14
  39. package/src/client/conversations/ConversationChildAgents.tsx +5 -5
  40. package/src/client/conversations/ConversationComposer.tsx +84 -12
  41. package/src/client/conversations/ConversationDetails.tsx +3 -27
  42. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  43. package/src/client/conversations/ConversationHeader.tsx +5 -4
  44. package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
  45. package/src/client/conversations/ConversationPage.tsx +156 -2
  46. package/src/client/conversations/ConversationSidebar.tsx +5 -4
  47. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  48. package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
  49. package/src/client/conversations/ConversationWatches.tsx +67 -59
  50. package/src/client/conversations/DetailsList.tsx +77 -29
  51. package/src/client/conversations/EventDetails.tsx +46 -19
  52. package/src/client/conversations/EventUsage.tsx +99 -74
  53. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  54. package/src/client/conversations/PendingMailboxStack.tsx +312 -119
  55. package/src/client/conversations/README.md +30 -10
  56. package/src/client/conversations/ShareConversationButton.tsx +3 -4
  57. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  58. package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
  59. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  60. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  61. package/src/client/conversations/TranscriptEventView.tsx +35 -16
  62. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  63. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  64. package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
  65. package/src/client/conversations/TranscriptMessageView.tsx +7 -1
  66. package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
  67. package/src/client/conversations/TranscriptSummary.tsx +13 -7
  68. package/src/client/conversations/TranscriptTurnContextView.tsx +78 -56
  69. package/src/client/conversations/TranscriptView.tsx +7 -3
  70. package/src/client/conversations/UserInputRequestCard.tsx +244 -0
  71. package/src/client/conversations/conversationOutbox.ts +46 -0
  72. package/src/client/conversations/eventLog.ts +1 -0
  73. package/src/client/conversations/eventTime.ts +13 -1
  74. package/src/client/conversations/eventTranscript.ts +12 -5
  75. package/src/client/conversations/queries.ts +127 -26
  76. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  77. package/src/client/conversations/transcriptMentions.tsx +18 -5
  78. package/src/client/conversations/userInputRequest.ts +56 -0
  79. package/src/client/format.ts +10 -3
  80. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  81. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  82. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  83. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  84. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  85. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  86. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  87. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  88. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  89. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  90. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  91. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  92. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  93. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  94. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  95. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  96. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  97. package/src/client/pages/automations/README.md +56 -46
  98. package/src/client/types.ts +3 -0
  99. package/src/mock-reporting/fixtures.ts +44 -2
  100. package/src/mock-reporting/watches.ts +1 -1
  101. package/src/client/conversations/transcriptStyles.ts +0 -10
  102. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  103. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  104. 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
- }
@@ -43,12 +43,17 @@ export function MentionPeopleProvider(props: {
43
43
 
44
44
  /** Render one line of display text with the people the API resolved for it. */
45
45
  export function MentionText(props: {
46
+ /**
47
+ * Link people to their page. Set false inside a button, which cannot hold
48
+ * a link.
49
+ */
50
+ links?: boolean;
46
51
  mentions: readonly ActorIdentity[];
47
52
  text: string;
48
53
  }) {
49
54
  return (
50
55
  <MentionPeopleProvider mentions={props.mentions}>
51
- {renderMentionText(props.text, "mention-text")}
56
+ {renderMentionText(props.text, "mention-text", props.links)}
52
57
  </MentionPeopleProvider>
53
58
  );
54
59
  }
@@ -57,7 +62,11 @@ export function MentionText(props: {
57
62
  * Render text with Slack `<@U123>` and `<#C123|name>` tokens as readable
58
63
  * mentions. It reads people from the nearest `MentionPeopleProvider`.
59
64
  */
60
- export function renderMentionText(text: string, keyBase: string): ReactNode[] {
65
+ export function renderMentionText(
66
+ text: string,
67
+ keyBase: string,
68
+ links = true,
69
+ ): ReactNode[] {
61
70
  const nodes: ReactNode[] = [];
62
71
  let cursor = 0;
63
72
  for (const mention of findMentions(text)) {
@@ -70,7 +79,11 @@ export function renderMentionText(text: string, keyBase: string): ReactNode[] {
70
79
  );
71
80
  }
72
81
  nodes.push(
73
- <MentionView key={`${keyBase}-m-${mention.start}`} mention={mention} />,
82
+ <MentionView
83
+ key={`${keyBase}-m-${mention.start}`}
84
+ links={links}
85
+ mention={mention}
86
+ />,
74
87
  );
75
88
  cursor = mention.end;
76
89
  }
@@ -95,13 +108,13 @@ function findMentions(text: string): TranscriptMention[] {
95
108
  }));
96
109
  }
97
110
 
98
- function MentionView(props: { mention: TranscriptMention }) {
111
+ function MentionView(props: { links: boolean; mention: TranscriptMention }) {
99
112
  const people = useContext(TranscriptMentionPeople);
100
113
  const { id, label, sigil } = props.mention;
101
114
  const person = sigil === "@" ? people.get(id) : undefined;
102
115
  const name = person?.fullName ?? person?.slackUserName ?? label ?? id;
103
116
  const text = <HighlightText text={`${sigil}${name.replace(/^[@#]/, "")}`} />;
104
- if (person?.email) {
117
+ if (props.links && person?.email) {
105
118
  return (
106
119
  <Link
107
120
  // Previews sit in cards that disable pointer events on their text.
@@ -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
+ }
@@ -796,9 +796,16 @@ export function workspacePath(workspaceId: string): string {
796
796
  return `/system/workspaces/${encodeURIComponent(workspaceId)}`;
797
797
  }
798
798
 
799
- /** Build the canonical automation detail route for an automation id. */
800
- export function automationPath(automationId: string): string {
801
- return `/automations/${encodeURIComponent(automationId)}`;
799
+ /** Automation page tabs. Overview is the bare Automation path. */
800
+ export type AutomationTab = "overview" | "runs" | "versions" | "edit";
801
+
802
+ /** Build the route for one Automation page tab, or its edit mode. */
803
+ export function automationPath(
804
+ automationId: string,
805
+ tab: AutomationTab = "overview",
806
+ ): string {
807
+ const base = `/automations/${encodeURIComponent(automationId)}`;
808
+ return tab === "overview" ? base : `${base}/${tab}`;
802
809
  }
803
810
 
804
811
  function normalizeLanguage(language: string | undefined): BundledLanguage {