@sentry/junior-dashboard 0.266.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 (73) 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/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 +4 -1
  13. package/src/client/components/README.md +11 -2
  14. package/src/client/components/ui/alert.tsx +1 -1
  15. package/src/client/components/ui/button-group.tsx +59 -0
  16. package/src/client/components/ui/dialog.tsx +29 -35
  17. package/src/client/components/ui/drawer.tsx +140 -0
  18. package/src/client/components/ui/empty.tsx +30 -1
  19. package/src/client/components/ui/separator.tsx +22 -0
  20. package/src/client/components/ui/useSheet.ts +5 -1
  21. package/src/client/conversations/ComposerImages.tsx +66 -14
  22. package/src/client/conversations/ConversationComposer.tsx +84 -12
  23. package/src/client/conversations/ConversationDetails.tsx +3 -27
  24. package/src/client/conversations/ConversationEventDetails.tsx +26 -10
  25. package/src/client/conversations/ConversationPage.tsx +156 -2
  26. package/src/client/conversations/ConversationTranscript.tsx +48 -16
  27. package/src/client/conversations/DetailsList.tsx +33 -0
  28. package/src/client/conversations/EventDetails.tsx +46 -19
  29. package/src/client/conversations/ForkConversationButton.tsx +79 -73
  30. package/src/client/conversations/PendingMailboxStack.tsx +310 -112
  31. package/src/client/conversations/README.md +30 -13
  32. package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
  33. package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
  34. package/src/client/conversations/TranscriptAttachment.tsx +39 -4
  35. package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
  36. package/src/client/conversations/TranscriptFailureView.tsx +10 -13
  37. package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
  38. package/src/client/conversations/TranscriptMessageView.tsx +7 -1
  39. package/src/client/conversations/TranscriptSummary.tsx +5 -31
  40. package/src/client/conversations/TranscriptTurnContextView.tsx +33 -18
  41. package/src/client/conversations/TranscriptView.tsx +7 -3
  42. package/src/client/conversations/UserInputRequestCard.tsx +244 -0
  43. package/src/client/conversations/conversationOutbox.ts +46 -0
  44. package/src/client/conversations/eventTime.ts +13 -1
  45. package/src/client/conversations/eventTranscript.ts +12 -5
  46. package/src/client/conversations/queries.ts +127 -26
  47. package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
  48. package/src/client/conversations/userInputRequest.ts +56 -0
  49. package/src/client/format.ts +10 -3
  50. package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
  51. package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
  52. package/src/client/pages/automations/AutomationEditor.tsx +182 -156
  53. package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
  54. package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
  55. package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
  56. package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
  57. package/src/client/pages/automations/AutomationOverview.tsx +238 -0
  58. package/src/client/pages/automations/AutomationPage.tsx +343 -0
  59. package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
  60. package/src/client/pages/automations/AutomationRow.tsx +11 -13
  61. package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
  62. package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
  63. package/src/client/pages/automations/AutomationStatus.tsx +164 -108
  64. package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
  65. package/src/client/pages/automations/AutomationsPage.tsx +10 -98
  66. package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
  67. package/src/client/pages/automations/README.md +56 -46
  68. package/src/client/types.ts +3 -0
  69. package/src/mock-reporting/fixtures.ts +44 -2
  70. package/src/client/conversations/transcriptStyles.ts +0 -10
  71. package/src/client/pages/automations/AutomationDetails.tsx +0 -193
  72. package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
  73. package/src/client/pages/automations/AutomationVersionsPage.tsx +0 -390
@@ -26,6 +26,7 @@ import {
26
26
  conversationEventPageSchema,
27
27
  conversationPendingMessagesReportSchema,
28
28
  forkConversationResponseSchema,
29
+ steerConversationPendingMessagesResponseSchema,
29
30
  stopConversationTurnResponseSchema,
30
31
  } from "@sentry/junior/api/schema";
31
32
 
@@ -42,8 +43,11 @@ import {
42
43
  conversationOutboxMessageForSubmit,
43
44
  conversationOutboxQueryKey,
44
45
  failConversationOutboxMessage,
46
+ isInlineOutboxMessage,
45
47
  mergeConversationMailboxMessages,
46
48
  acceptConversationOutboxMessage,
49
+ reuseConversationMailboxMessages,
50
+ splitInlineMailboxMessages,
47
51
  upsertConversationOutboxMessage,
48
52
  type ConversationMailboxMessage,
49
53
  type ConversationOutboxMessage,
@@ -130,6 +134,11 @@ export function usePendingArchiveConversationUpdates() {
130
134
  });
131
135
  }
132
136
 
137
+ // Right after a send, Junior usually reads the message within a few seconds.
138
+ // Poll fast for that window only, then fall back to the normal live interval.
139
+ const FAST_POLL_INTERVAL_MS = 500;
140
+ const FAST_POLL_WINDOW_MS = 6_000;
141
+
133
142
  type ConversationSnapshot = ConversationDetailReport & {
134
143
  mailbox?: ConversationPendingMessagesReport;
135
144
  detailEtag?: string;
@@ -149,7 +158,11 @@ async function readConversationSnapshot(
149
158
  /** Refresh history and mailbox as one visible snapshot, including idle input. */
150
159
  export function conversationDetailQueryOptions(
151
160
  conversationId: string | undefined,
152
- options?: { awaitingMessage?: boolean },
161
+ options?: {
162
+ awaitingMessage?: boolean;
163
+ /** Poll fast until this time so a new send reaches history quickly. */
164
+ fastPollUntilMs?: number;
165
+ },
153
166
  ) {
154
167
  return queryOptions({
155
168
  enabled: Boolean(conversationId),
@@ -171,12 +184,14 @@ export function conversationDetailQueryOptions(
171
184
  );
172
185
  },
173
186
  refetchInterval: (query) =>
174
- options?.awaitingMessage ||
175
- query.state.data?.status === "active" ||
176
- Boolean(query.state.data?.mailbox?.messages.length) ||
177
- Boolean(query.state.data?.mailbox?.authorization)
178
- ? 2_000
179
- : 10_000,
187
+ options?.fastPollUntilMs && Date.now() < options.fastPollUntilMs
188
+ ? FAST_POLL_INTERVAL_MS
189
+ : options?.awaitingMessage ||
190
+ query.state.data?.status === "active" ||
191
+ Boolean(query.state.data?.mailbox?.messages.length) ||
192
+ Boolean(query.state.data?.mailbox?.authorization)
193
+ ? 2_000
194
+ : 10_000,
180
195
  retry: false,
181
196
  });
182
197
  }
@@ -213,6 +228,8 @@ export function useCreateConversation() {
213
228
  ...conversationOutboxMessageForSubmit({
214
229
  ...args,
215
230
  actorIdentity: outboxActorIdentity(queryClient),
231
+ // A new Conversation has no Turn or queue yet.
232
+ inline: true,
216
233
  messageId: accepted.messageId,
217
234
  }),
218
235
  status: "accepted",
@@ -261,6 +278,8 @@ export function useForkConversation(sourceConversationId: string) {
261
278
  idempotencyKey: args.idempotencyKey,
262
279
  message: args.message,
263
280
  actorIdentity: outboxActorIdentity(queryClient),
281
+ // A new fork has no Turn or queue yet.
282
+ inline: true,
264
283
  messageId: accepted.messageId,
265
284
  }),
266
285
  status: "accepted",
@@ -278,15 +297,20 @@ export function useAppendConversationMessage(conversationId: string) {
278
297
  const queryClient = useQueryClient();
279
298
  const outboxQueryKey = conversationOutboxQueryKey(conversationId);
280
299
  return useMutation({
281
- mutationFn: (args: {
300
+ mutationFn: ({
301
+ inline: _inline,
302
+ ...body
303
+ }: {
282
304
  idempotencyKey: string;
305
+ /** Show the send in the transcript instead of the queue. */
306
+ inline?: boolean;
283
307
  message: string;
284
308
  images?: InputImage[];
285
309
  }) =>
286
310
  post(
287
311
  acceptedConversationMessageSchema,
288
312
  `/api/conversations/${encodeURIComponent(conversationId)}/messages`,
289
- args,
313
+ body,
290
314
  ),
291
315
  onMutate: async (args) => {
292
316
  // Use the durable id from the first render. No server read is needed,
@@ -396,6 +420,52 @@ export function useCancelConversationPendingMessages(conversationId: string) {
396
420
  });
397
421
  }
398
422
 
423
+ /** Ask Junior to read the viewer's own queued web messages now. */
424
+ export function useSteerConversationPendingMessages(conversationId: string) {
425
+ const queryClient = useQueryClient();
426
+ return useMutation({
427
+ mutationFn: (args: { inboundMessageIds: string[] }) =>
428
+ post(
429
+ steerConversationPendingMessagesResponseSchema,
430
+ `/api/conversations/${encodeURIComponent(conversationId)}/pending-messages/steer`,
431
+ args,
432
+ ),
433
+ onSuccess: async (result) => {
434
+ const steered = new Set(result.steeredInboundMessageIds);
435
+ if (steered.size === 0) return;
436
+ // A live poll that read the mailbox before the steer would restore
437
+ // `defer`. Cancel it; onSettled refetches after the write.
438
+ await queryClient.cancelQueries({
439
+ exact: true,
440
+ queryKey: conversationDetailQueryKey(conversationId),
441
+ });
442
+ queryClient.setQueryData<ConversationSnapshot>(
443
+ conversationDetailQueryKey(conversationId),
444
+ (current) =>
445
+ current?.mailbox
446
+ ? {
447
+ ...current,
448
+ mailbox: {
449
+ ...current.mailbox,
450
+ messages: current.mailbox.messages.map((message) =>
451
+ steered.has(message.inboundMessageId)
452
+ ? { ...message, delivery: "interrupt" as const }
453
+ : message,
454
+ ),
455
+ },
456
+ }
457
+ : current,
458
+ );
459
+ },
460
+ onSettled: async () => {
461
+ await queryClient.invalidateQueries({
462
+ exact: true,
463
+ queryKey: conversationDetailQueryKey(conversationId),
464
+ });
465
+ },
466
+ });
467
+ }
468
+
399
469
  /**
400
470
  * Local cache of queued rows that a stop removed. The worker drops them only
401
471
  * when it observes the stop, so the mailbox can still list them for a while.
@@ -627,11 +697,20 @@ export function useConversationData(conversationId: string | undefined) {
627
697
  staleTime: Infinity,
628
698
  gcTime: Infinity,
629
699
  });
630
- const awaitingMessage = outbox.data.some(
700
+ const awaitingRows = outbox.data.filter(
631
701
  (message) => message.status !== "failed",
632
702
  );
703
+ const latestSendAtMs = Math.max(
704
+ 0,
705
+ ...awaitingRows.map((message) => Date.parse(message.createdAt)),
706
+ );
633
707
  const detail = useQuery(
634
- conversationDetailQueryOptions(conversationId, { awaitingMessage }),
708
+ conversationDetailQueryOptions(conversationId, {
709
+ awaitingMessage: awaitingRows.length > 0,
710
+ fastPollUntilMs: latestSendAtMs
711
+ ? latestSendAtMs + FAST_POLL_WINDOW_MS
712
+ : undefined,
713
+ }),
635
714
  );
636
715
  // Defer the whole server snapshot, not history alone. Queue removal and the
637
716
  // matching transcript must paint together while composer input stays urgent.
@@ -647,17 +726,23 @@ export function useConversationData(conversationId: string | undefined) {
647
726
  // contains its real Message id. History and mailbox arrive in one render.
648
727
  useEffect(() => {
649
728
  if (!mailbox || !detailData) return;
650
- const observedIds = new Set(
729
+ const queuedIds = new Set(
651
730
  mailbox.messages.map((message) => message.messageId),
652
731
  );
732
+ const historyIds = new Set<string>();
653
733
  for (const event of detailData.events) {
654
- if (event.data.type === "message") observedIds.add(event.data.messageId);
734
+ if (event.data.type === "message") historyIds.add(event.data.messageId);
655
735
  }
656
736
  queryClient.setQueryData<ConversationOutboxMessage[]>(
657
737
  conversationOutboxQueryKey(conversationId),
658
738
  (current) => {
739
+ // An inline row stays until history has it. The mailbox copy alone
740
+ // would put it back in the queue.
659
741
  const next = current?.filter(
660
- (message) => !observedIds.has(message.messageId),
742
+ (message) =>
743
+ !historyIds.has(message.messageId) &&
744
+ (isInlineOutboxMessage(message) ||
745
+ !queuedIds.has(message.messageId)),
661
746
  );
662
747
  return next?.length === current?.length ? current : next;
663
748
  },
@@ -727,23 +812,37 @@ export function useConversationData(conversationId: string | undefined) {
727
812
  const pendingMessagesRef = useRef<ConversationMailboxMessage[] | undefined>(
728
813
  undefined,
729
814
  );
730
- const pendingMessages = useMemo(() => {
815
+ const inlineMessagesRef = useRef<ConversationMailboxMessage[] | undefined>(
816
+ undefined,
817
+ );
818
+ const { inlineMessages, pendingMessages } = useMemo(() => {
731
819
  // Hide stopped rows from both sources, or the outbox copy of a stopped
732
820
  // send would look unseen and come back.
733
821
  const stopped = stoppedMessageIds.data;
734
- const next = mergeConversationMailboxMessages(
735
- stopped.length
736
- ? mailbox?.messages.filter(
737
- (message) => !stopped.includes(message.inboundMessageId),
738
- )
739
- : mailbox?.messages,
740
- stopped.length
741
- ? outbox.data.filter((message) => !stopped.includes(message.messageId))
742
- : outbox.data,
822
+ const server = stopped.length
823
+ ? mailbox?.messages.filter(
824
+ (message) => !stopped.includes(message.inboundMessageId),
825
+ )
826
+ : mailbox?.messages;
827
+ const local = stopped.length
828
+ ? outbox.data.filter((message) => !stopped.includes(message.messageId))
829
+ : outbox.data;
830
+ const split = splitInlineMailboxMessages(
831
+ mergeConversationMailboxMessages(server, local),
832
+ server,
833
+ local,
834
+ );
835
+ const queued = reuseConversationMailboxMessages(
743
836
  pendingMessagesRef.current,
837
+ split.queued,
838
+ );
839
+ const inline = reuseConversationMailboxMessages(
840
+ inlineMessagesRef.current,
841
+ split.inline,
744
842
  );
745
- pendingMessagesRef.current = next;
746
- return next;
843
+ pendingMessagesRef.current = queued;
844
+ inlineMessagesRef.current = inline;
845
+ return { inlineMessages: inline, pendingMessages: queued };
747
846
  }, [outbox.data, mailbox?.messages, stoppedMessageIds.data]);
748
847
  const invalidHistoryCursor = isInvalidCursorError(history.error);
749
848
  const shouldRefreshDetail = Boolean(
@@ -797,6 +896,8 @@ export function useConversationData(conversationId: string | undefined) {
797
896
  isLoadingPreviousPage,
798
897
  pendingAuthorization: mailbox?.authorization,
799
898
  pendingGeneratedAt: mailbox?.generatedAt,
899
+ /** The reader's own sends that the transcript shows before history has them. */
900
+ inlineMessages,
800
901
  pendingMessages,
801
902
  loadCompleteTranscript: () => {
802
903
  if (!conversationId || !detailData) {
@@ -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
- }
@@ -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
+ }