@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.
- package/dist/app.js +49 -12
- package/dist/client.js +94 -63
- package/dist/index.js +49 -12
- package/dist/tailwind.css +1 -1
- package/package.json +11 -5
- package/src/app.ts +0 -2
- package/src/auth.ts +4 -7
- package/src/client/App.tsx +35 -53
- package/src/client/components/DetailsPanel.tsx +12 -13
- package/src/client/components/Drawer.tsx +46 -23
- package/src/client/components/ImageAttachment.tsx +1 -1
- package/src/client/components/ParticipantAvatarStack.tsx +4 -1
- package/src/client/components/README.md +11 -2
- package/src/client/components/ui/alert.tsx +1 -1
- package/src/client/components/ui/button-group.tsx +59 -0
- package/src/client/components/ui/dialog.tsx +29 -35
- package/src/client/components/ui/drawer.tsx +140 -0
- package/src/client/components/ui/empty.tsx +30 -1
- package/src/client/components/ui/separator.tsx +22 -0
- package/src/client/components/ui/useSheet.ts +5 -1
- package/src/client/conversations/ComposerImages.tsx +66 -14
- package/src/client/conversations/ConversationComposer.tsx +84 -12
- package/src/client/conversations/ConversationDetails.tsx +3 -27
- package/src/client/conversations/ConversationEventDetails.tsx +26 -10
- package/src/client/conversations/ConversationPage.tsx +156 -2
- package/src/client/conversations/ConversationTranscript.tsx +48 -16
- package/src/client/conversations/DetailsList.tsx +33 -0
- package/src/client/conversations/EventDetails.tsx +46 -19
- package/src/client/conversations/ForkConversationButton.tsx +79 -73
- package/src/client/conversations/PendingMailboxStack.tsx +310 -112
- package/src/client/conversations/README.md +30 -13
- package/src/client/conversations/SubagentTranscriptDrawer.tsx +19 -22
- package/src/client/conversations/TranscriptActivityGroup.tsx +22 -19
- package/src/client/conversations/TranscriptAttachment.tsx +39 -4
- package/src/client/conversations/TranscriptAutomationTriggerView.tsx +14 -19
- package/src/client/conversations/TranscriptFailureView.tsx +10 -13
- package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
- package/src/client/conversations/TranscriptMessageView.tsx +7 -1
- package/src/client/conversations/TranscriptSummary.tsx +5 -31
- package/src/client/conversations/TranscriptTurnContextView.tsx +33 -18
- package/src/client/conversations/TranscriptView.tsx +7 -3
- package/src/client/conversations/UserInputRequestCard.tsx +244 -0
- package/src/client/conversations/conversationOutbox.ts +46 -0
- package/src/client/conversations/eventTime.ts +13 -1
- package/src/client/conversations/eventTranscript.ts +12 -5
- package/src/client/conversations/queries.ts +127 -26
- package/src/client/conversations/transcriptMarkdownLinks.ts +5 -367
- package/src/client/conversations/userInputRequest.ts +56 -0
- package/src/client/format.ts +10 -3
- package/src/client/pages/automations/{AutomationLifecycle.tsx → AutomationActions.tsx} +60 -69
- package/src/client/pages/automations/AutomationDraftChanges.tsx +171 -0
- package/src/client/pages/automations/AutomationEditor.tsx +182 -156
- package/src/client/pages/automations/AutomationEventFields.tsx +119 -45
- package/src/client/pages/automations/AutomationExecutionStatusChart.tsx +1 -1
- package/src/client/pages/automations/AutomationFormSection.tsx +20 -2
- package/src/client/pages/automations/AutomationOutcomeFields.tsx +1 -1
- package/src/client/pages/automations/AutomationOverview.tsx +238 -0
- package/src/client/pages/automations/AutomationPage.tsx +343 -0
- package/src/client/pages/automations/AutomationRecentRuns.tsx +3 -2
- package/src/client/pages/automations/AutomationRow.tsx +11 -13
- package/src/client/pages/automations/AutomationRunDetails.tsx +4 -2
- package/src/client/pages/automations/{AutomationExecutionsPage.tsx → AutomationRunsTab.tsx} +94 -103
- package/src/client/pages/automations/AutomationStatus.tsx +164 -108
- package/src/client/pages/automations/AutomationVersionsTab.tsx +518 -0
- package/src/client/pages/automations/AutomationsPage.tsx +10 -98
- package/src/client/pages/automations/AutomationsPageLayout.tsx +5 -23
- package/src/client/pages/automations/README.md +56 -46
- package/src/client/types.ts +3 -0
- package/src/mock-reporting/fixtures.ts +44 -2
- package/src/client/conversations/transcriptStyles.ts +0 -10
- package/src/client/pages/automations/AutomationDetails.tsx +0 -193
- package/src/client/pages/automations/AutomationEditPage.tsx +0 -176
- 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?: {
|
|
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?.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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
|
|
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")
|
|
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) =>
|
|
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
|
|
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
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
stopped.
|
|
741
|
-
|
|
742
|
-
|
|
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 =
|
|
746
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
+
}
|