@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.
- 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/ActorAvatar.tsx +10 -22
- package/src/client/components/ConnectionBanner.tsx +4 -3
- package/src/client/components/CopyButton.tsx +3 -4
- package/src/client/components/DetailsPanel.tsx +12 -13
- package/src/client/components/Drawer.tsx +46 -23
- package/src/client/components/EmptyTelemetry.tsx +5 -3
- package/src/client/components/ImageAttachment.tsx +49 -63
- package/src/client/components/InlineError.tsx +8 -4
- package/src/client/components/Notice.tsx +19 -26
- package/src/client/components/Pagination.tsx +26 -26
- package/src/client/components/ParticipantAvatarStack.tsx +13 -10
- package/src/client/components/README.md +28 -3
- package/src/client/components/SearchInput.tsx +51 -41
- package/src/client/components/VersionDriftBanner.tsx +9 -7
- package/src/client/components/charts/CacheWrites.tsx +52 -43
- package/src/client/components/ui/alert.tsx +64 -0
- package/src/client/components/ui/avatar.tsx +53 -0
- package/src/client/components/ui/button-group.tsx +59 -0
- package/src/client/components/ui/button.tsx +2 -1
- package/src/client/components/ui/dialog.tsx +60 -0
- package/src/client/components/ui/drawer.tsx +140 -0
- package/src/client/components/ui/empty.tsx +61 -0
- package/src/client/components/ui/input-group.tsx +96 -0
- package/src/client/components/ui/pagination.tsx +60 -0
- package/src/client/components/ui/separator.tsx +22 -0
- package/src/client/components/ui/spinner.tsx +21 -0
- package/src/client/components/ui/table.tsx +22 -1
- package/src/client/components/ui/tooltip.tsx +1 -1
- package/src/client/components/ui/useSheet.ts +5 -1
- package/src/client/conversations/ComposerImages.tsx +66 -14
- package/src/client/conversations/ConversationChildAgents.tsx +5 -5
- 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/ConversationHeader.tsx +5 -4
- package/src/client/conversations/ConversationHeaderActions.tsx +7 -11
- package/src/client/conversations/ConversationPage.tsx +156 -2
- package/src/client/conversations/ConversationSidebar.tsx +5 -4
- package/src/client/conversations/ConversationTranscript.tsx +48 -16
- package/src/client/conversations/ConversationUsageBreakdown.tsx +108 -65
- package/src/client/conversations/ConversationWatches.tsx +67 -59
- package/src/client/conversations/DetailsList.tsx +77 -29
- package/src/client/conversations/EventDetails.tsx +46 -19
- package/src/client/conversations/EventUsage.tsx +99 -74
- package/src/client/conversations/ForkConversationButton.tsx +79 -73
- package/src/client/conversations/PendingMailboxStack.tsx +312 -119
- package/src/client/conversations/README.md +30 -10
- package/src/client/conversations/ShareConversationButton.tsx +3 -4
- 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/TranscriptEventView.tsx +35 -16
- package/src/client/conversations/TranscriptFailureView.tsx +10 -13
- package/src/client/conversations/TranscriptMarkdown.tsx +232 -436
- package/src/client/conversations/TranscriptMessageContextView.tsx +31 -20
- package/src/client/conversations/TranscriptMessageView.tsx +7 -1
- package/src/client/conversations/TranscriptStructuredEventView.tsx +25 -15
- package/src/client/conversations/TranscriptSummary.tsx +13 -7
- package/src/client/conversations/TranscriptTurnContextView.tsx +78 -56
- 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/eventLog.ts +1 -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/transcriptMentions.tsx +18 -5
- 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/mock-reporting/watches.ts +1 -1
- 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
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { useId, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buttonUserInputField,
|
|
4
|
+
formatUserInputAnswer,
|
|
5
|
+
isEmptyUserInputAnswer,
|
|
6
|
+
normalizeUserInputAnswer,
|
|
7
|
+
type UserInputAnswer,
|
|
8
|
+
type UserInputField,
|
|
9
|
+
} from "@sentry/junior/api/schema";
|
|
10
|
+
|
|
11
|
+
import { Button } from "../components/ui/button";
|
|
12
|
+
import { Textarea } from "../components/ui/textarea";
|
|
13
|
+
import { TranscriptText } from "./TranscriptText";
|
|
14
|
+
import type { OpenUserInputRequest } from "./userInputRequest";
|
|
15
|
+
|
|
16
|
+
const labelClass = "font-sans text-xs font-medium text-dashboard-text-muted";
|
|
17
|
+
|
|
18
|
+
function ChoiceField(props: {
|
|
19
|
+
disabled: boolean;
|
|
20
|
+
field: Extract<UserInputField, { kind: "choice" | "multi_choice" }>;
|
|
21
|
+
onChange(values: string[]): void;
|
|
22
|
+
values: string[];
|
|
23
|
+
}) {
|
|
24
|
+
const name = useId();
|
|
25
|
+
const multi = props.field.kind === "multi_choice";
|
|
26
|
+
return (
|
|
27
|
+
<fieldset className="m-0 min-w-0 border-0 p-0">
|
|
28
|
+
<legend className={labelClass}>{props.field.label}</legend>
|
|
29
|
+
{props.field.description ? (
|
|
30
|
+
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
31
|
+
{props.field.description}
|
|
32
|
+
</p>
|
|
33
|
+
) : null}
|
|
34
|
+
<div className="mt-1 grid gap-1">
|
|
35
|
+
{props.field.options.map((option) => (
|
|
36
|
+
<label
|
|
37
|
+
className="flex cursor-pointer items-start gap-2 text-sm"
|
|
38
|
+
key={option.label}
|
|
39
|
+
>
|
|
40
|
+
<input
|
|
41
|
+
checked={props.values.includes(option.label)}
|
|
42
|
+
className="mt-0.5 size-4 shrink-0 accent-dashboard-focus"
|
|
43
|
+
disabled={props.disabled}
|
|
44
|
+
name={name}
|
|
45
|
+
onChange={() =>
|
|
46
|
+
props.onChange(
|
|
47
|
+
!multi
|
|
48
|
+
? [option.label]
|
|
49
|
+
: props.values.includes(option.label)
|
|
50
|
+
? props.values.filter((value) => value !== option.label)
|
|
51
|
+
: [...props.values, option.label],
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
type={multi ? "checkbox" : "radio"}
|
|
55
|
+
/>
|
|
56
|
+
<span className="min-w-0 break-words">
|
|
57
|
+
{option.label}
|
|
58
|
+
{option.description ? (
|
|
59
|
+
<span className="block text-xs text-dashboard-text-muted">
|
|
60
|
+
{option.description}
|
|
61
|
+
</span>
|
|
62
|
+
) : null}
|
|
63
|
+
</span>
|
|
64
|
+
</label>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</fieldset>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function TextField(props: {
|
|
72
|
+
description?: string;
|
|
73
|
+
disabled: boolean;
|
|
74
|
+
label: string;
|
|
75
|
+
onChange(value: string): void;
|
|
76
|
+
value: string;
|
|
77
|
+
}) {
|
|
78
|
+
const id = useId();
|
|
79
|
+
return (
|
|
80
|
+
<div className="grid gap-1">
|
|
81
|
+
<label className={labelClass} htmlFor={id}>
|
|
82
|
+
{props.label}
|
|
83
|
+
</label>
|
|
84
|
+
{props.description ? (
|
|
85
|
+
<p className="m-0 text-xs text-dashboard-text-muted">
|
|
86
|
+
{props.description}
|
|
87
|
+
</p>
|
|
88
|
+
) : null}
|
|
89
|
+
<Textarea
|
|
90
|
+
className="min-h-16 leading-relaxed"
|
|
91
|
+
disabled={props.disabled}
|
|
92
|
+
id={id}
|
|
93
|
+
onChange={(event) => props.onChange(event.target.value)}
|
|
94
|
+
value={props.value}
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Show Junior's open request above the composer. A request with one short
|
|
102
|
+
* choice field shows one button per choice, and a click is the answer. Other
|
|
103
|
+
* requests show a form that the reader submits once. Both send the formatted
|
|
104
|
+
* answer as the reader's next message. Typing a reply also answers it.
|
|
105
|
+
*/
|
|
106
|
+
export function UserInputRequestCard(props: {
|
|
107
|
+
onAnswer(answer: string): Promise<void>;
|
|
108
|
+
request: OpenUserInputRequest;
|
|
109
|
+
}) {
|
|
110
|
+
const [sending, setSending] = useState(false);
|
|
111
|
+
const [failed, setFailed] = useState(false);
|
|
112
|
+
const [values, setValues] = useState<Record<string, string[]>>({});
|
|
113
|
+
const [other, setOther] = useState("");
|
|
114
|
+
const buttonField = buttonUserInputField(props.request);
|
|
115
|
+
|
|
116
|
+
// A ref blocks a second click before React renders the disabled controls.
|
|
117
|
+
// The lock stays after a sent answer; only a failed send can retry.
|
|
118
|
+
const sendLock = useRef(false);
|
|
119
|
+
|
|
120
|
+
async function send(answer: UserInputAnswer) {
|
|
121
|
+
if (sendLock.current) return;
|
|
122
|
+
sendLock.current = true;
|
|
123
|
+
setSending(true);
|
|
124
|
+
setFailed(false);
|
|
125
|
+
try {
|
|
126
|
+
await props.onAnswer(formatUserInputAnswer(props.request, answer));
|
|
127
|
+
} catch {
|
|
128
|
+
sendLock.current = false;
|
|
129
|
+
setSending(false);
|
|
130
|
+
setFailed(true);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const answer: UserInputAnswer = {
|
|
135
|
+
other,
|
|
136
|
+
requestId: props.request.toolCallId,
|
|
137
|
+
values,
|
|
138
|
+
};
|
|
139
|
+
function submit(event: FormEvent) {
|
|
140
|
+
event.preventDefault();
|
|
141
|
+
if (
|
|
142
|
+
!isEmptyUserInputAnswer(normalizeUserInputAnswer(props.request, answer))
|
|
143
|
+
) {
|
|
144
|
+
void send(answer);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<section
|
|
150
|
+
aria-label="Junior asks"
|
|
151
|
+
className="mb-2 rounded-lg border border-cyan-300/20 bg-cyan-300/[0.07] px-3 py-2.5 font-sans text-sm text-dashboard-text"
|
|
152
|
+
>
|
|
153
|
+
<div className="max-h-48 overflow-y-auto">
|
|
154
|
+
<TranscriptText
|
|
155
|
+
mentions={[]}
|
|
156
|
+
role="assistant"
|
|
157
|
+
text={props.request.question}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
{buttonField ? (
|
|
161
|
+
<>
|
|
162
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
163
|
+
{buttonField.options.map((option) => (
|
|
164
|
+
<Button
|
|
165
|
+
disabled={sending}
|
|
166
|
+
key={option.label}
|
|
167
|
+
onClick={() =>
|
|
168
|
+
void send({
|
|
169
|
+
requestId: props.request.toolCallId,
|
|
170
|
+
values: { [buttonField.id]: [option.label] },
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
title={option.description}
|
|
174
|
+
>
|
|
175
|
+
{option.label}
|
|
176
|
+
</Button>
|
|
177
|
+
))}
|
|
178
|
+
</div>
|
|
179
|
+
{buttonField.options.some((option) => option.description) ? (
|
|
180
|
+
<ul className="mb-0 mt-2 list-none p-0 text-xs text-dashboard-text-muted">
|
|
181
|
+
{buttonField.options
|
|
182
|
+
.filter((option) => option.description)
|
|
183
|
+
.map((option) => (
|
|
184
|
+
<li key={option.label}>
|
|
185
|
+
<span className="font-medium text-dashboard-text">
|
|
186
|
+
{option.label}
|
|
187
|
+
</span>
|
|
188
|
+
: {option.description}
|
|
189
|
+
</li>
|
|
190
|
+
))}
|
|
191
|
+
</ul>
|
|
192
|
+
) : null}
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<form
|
|
196
|
+
className="mt-2 grid max-h-96 gap-3 overflow-y-auto"
|
|
197
|
+
onSubmit={submit}
|
|
198
|
+
>
|
|
199
|
+
{props.request.fields.map((field) =>
|
|
200
|
+
field.kind === "text" ? (
|
|
201
|
+
<TextField
|
|
202
|
+
description={field.description}
|
|
203
|
+
disabled={sending}
|
|
204
|
+
key={field.id}
|
|
205
|
+
label={field.label}
|
|
206
|
+
onChange={(value) =>
|
|
207
|
+
setValues((current) => ({ ...current, [field.id]: [value] }))
|
|
208
|
+
}
|
|
209
|
+
value={values[field.id]?.[0] ?? ""}
|
|
210
|
+
/>
|
|
211
|
+
) : (
|
|
212
|
+
<ChoiceField
|
|
213
|
+
disabled={sending}
|
|
214
|
+
field={field}
|
|
215
|
+
key={field.id}
|
|
216
|
+
onChange={(picked) =>
|
|
217
|
+
setValues((current) => ({ ...current, [field.id]: picked }))
|
|
218
|
+
}
|
|
219
|
+
values={values[field.id] ?? []}
|
|
220
|
+
/>
|
|
221
|
+
),
|
|
222
|
+
)}
|
|
223
|
+
<TextField
|
|
224
|
+
description="Anything else, or an answer that is not listed."
|
|
225
|
+
disabled={sending}
|
|
226
|
+
label="Other"
|
|
227
|
+
onChange={setOther}
|
|
228
|
+
value={other}
|
|
229
|
+
/>
|
|
230
|
+
<div>
|
|
231
|
+
<Button disabled={sending} type="submit" variant="primary">
|
|
232
|
+
Send
|
|
233
|
+
</Button>
|
|
234
|
+
</div>
|
|
235
|
+
</form>
|
|
236
|
+
)}
|
|
237
|
+
{failed ? (
|
|
238
|
+
<p className="mb-0 mt-2 text-xs text-rose-300" role="alert">
|
|
239
|
+
Could not send your answer. Try again.
|
|
240
|
+
</p>
|
|
241
|
+
) : null}
|
|
242
|
+
</section>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -9,6 +9,11 @@ export type ConversationOutboxMessage = {
|
|
|
9
9
|
actorIdentity?: ActorIdentity;
|
|
10
10
|
createdAt: string;
|
|
11
11
|
idempotencyKey: string;
|
|
12
|
+
/**
|
|
13
|
+
* Sent while Junior was idle and the queue was empty. The transcript shows
|
|
14
|
+
* the row in place, so it does not pass through the queue first.
|
|
15
|
+
*/
|
|
16
|
+
inline?: boolean;
|
|
12
17
|
message: string;
|
|
13
18
|
images?: InputImage[];
|
|
14
19
|
messageId: string;
|
|
@@ -31,6 +36,7 @@ export function conversationOutboxQueryKey(conversationId: string | undefined) {
|
|
|
31
36
|
export function conversationOutboxMessageForSubmit(input: {
|
|
32
37
|
actorIdentity?: ActorIdentity;
|
|
33
38
|
idempotencyKey: string;
|
|
39
|
+
inline?: boolean;
|
|
34
40
|
message: string;
|
|
35
41
|
images?: InputImage[];
|
|
36
42
|
messageId: string;
|
|
@@ -41,6 +47,7 @@ export function conversationOutboxMessageForSubmit(input: {
|
|
|
41
47
|
actorIdentity: input.actorIdentity,
|
|
42
48
|
createdAt,
|
|
43
49
|
idempotencyKey: input.idempotencyKey,
|
|
50
|
+
...(input.inline ? { inline: true } : undefined),
|
|
44
51
|
message: input.message,
|
|
45
52
|
...(input.images?.length ? { images: input.images } : undefined),
|
|
46
53
|
messageId: input.messageId,
|
|
@@ -153,6 +160,45 @@ function sameActorIdentity(
|
|
|
153
160
|
);
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
/** True while an outbox row belongs in the transcript instead of the queue. */
|
|
164
|
+
export function isInlineOutboxMessage(
|
|
165
|
+
message: ConversationOutboxMessage,
|
|
166
|
+
): boolean {
|
|
167
|
+
// A failed send moves to the queue, which owns the retry action.
|
|
168
|
+
return Boolean(message.inline) && message.status !== "failed";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Split pending rows between the queue and the transcript.
|
|
173
|
+
*
|
|
174
|
+
* Inline rows prefer the server copy once the mailbox has it, so attachments
|
|
175
|
+
* and identity match what the queue would show.
|
|
176
|
+
*/
|
|
177
|
+
export function splitInlineMailboxMessages(
|
|
178
|
+
merged: readonly ConversationMailboxMessage[],
|
|
179
|
+
server: readonly ConversationPendingMessage[] | undefined,
|
|
180
|
+
outbox: readonly ConversationOutboxMessage[],
|
|
181
|
+
): {
|
|
182
|
+
inline: ConversationMailboxMessage[];
|
|
183
|
+
queued: ConversationMailboxMessage[];
|
|
184
|
+
} {
|
|
185
|
+
const inlineRows = outbox.filter(isInlineOutboxMessage);
|
|
186
|
+
if (inlineRows.length === 0) {
|
|
187
|
+
return { inline: [], queued: merged as ConversationMailboxMessage[] };
|
|
188
|
+
}
|
|
189
|
+
const inlineIds = new Set(inlineRows.map((message) => message.messageId));
|
|
190
|
+
const serverById = new Map(
|
|
191
|
+
(server ?? []).map((message) => [message.messageId, message]),
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
inline: inlineRows.map(
|
|
195
|
+
(message) =>
|
|
196
|
+
serverById.get(message.messageId) ?? mailboxMessageFromOutbox(message),
|
|
197
|
+
),
|
|
198
|
+
queued: merged.filter((message) => !inlineIds.has(message.messageId)),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
156
202
|
/** Append or replace one outbox row by idempotency key. */
|
|
157
203
|
export function upsertConversationOutboxMessage(
|
|
158
204
|
current: readonly ConversationOutboxMessage[] | undefined,
|
|
@@ -111,6 +111,7 @@ export function eventLogTone(data: ConversationReportEventData): string {
|
|
|
111
111
|
}
|
|
112
112
|
if (
|
|
113
113
|
(data.type === "guardian_action_reviewed" && data.decision === "ask") ||
|
|
114
|
+
(data.type === "turn_lifecycle" && data.state === "blocked") ||
|
|
114
115
|
(data.type === "subagent" &&
|
|
115
116
|
(data.status === "aborted" || data.status === "blocked"))
|
|
116
117
|
) {
|
|
@@ -28,6 +28,15 @@ export function useEventTime() {
|
|
|
28
28
|
day: "numeric",
|
|
29
29
|
timeZoneName: "short",
|
|
30
30
|
}),
|
|
31
|
+
// The details header is narrow, so it leaves out milliseconds.
|
|
32
|
+
shortTimestamp: new Intl.DateTimeFormat(undefined, {
|
|
33
|
+
...clock,
|
|
34
|
+
fractionalSecondDigits: undefined,
|
|
35
|
+
year: "numeric",
|
|
36
|
+
month: "short",
|
|
37
|
+
day: "numeric",
|
|
38
|
+
timeZoneName: "short",
|
|
39
|
+
}),
|
|
31
40
|
// A log can span both standard time and daylight-saving time.
|
|
32
41
|
zone: new Intl.DateTimeFormat(undefined, {
|
|
33
42
|
timeZone,
|
|
@@ -54,7 +63,10 @@ export function useEventTime() {
|
|
|
54
63
|
return formatters.clock.format(new Date(value));
|
|
55
64
|
},
|
|
56
65
|
formatTimestamp(value: string) {
|
|
57
|
-
return
|
|
66
|
+
return formatters.timestamp.format(new Date(value));
|
|
67
|
+
},
|
|
68
|
+
formatShortTimestamp(value: string) {
|
|
69
|
+
return formatters.shortTimestamp.format(new Date(value));
|
|
58
70
|
},
|
|
59
71
|
};
|
|
60
72
|
}
|
|
@@ -2,6 +2,7 @@ import { resolveMessageCards } from "@sentry/junior/api/schema";
|
|
|
2
2
|
import type {
|
|
3
3
|
ConversationPendingMessage,
|
|
4
4
|
ConversationReportEvent,
|
|
5
|
+
InputImage,
|
|
5
6
|
} from "@sentry/junior/api/schema";
|
|
6
7
|
|
|
7
8
|
import type {
|
|
@@ -10,6 +11,11 @@ import type {
|
|
|
10
11
|
TranscriptViewPart,
|
|
11
12
|
} from "../types";
|
|
12
13
|
|
|
14
|
+
/** A mailbox row, or a local send that can still carry its own images. */
|
|
15
|
+
type PendingTranscriptInput = ConversationPendingMessage & {
|
|
16
|
+
images?: InputImage[];
|
|
17
|
+
};
|
|
18
|
+
|
|
13
19
|
type ToolCall =
|
|
14
20
|
| Extract<
|
|
15
21
|
ConversationReportEvent["data"],
|
|
@@ -67,7 +73,7 @@ function historyMessageIds(
|
|
|
67
73
|
|
|
68
74
|
/** Project one accepted mailbox row into a pending transcript message. */
|
|
69
75
|
export function pendingTranscriptMessage(
|
|
70
|
-
message:
|
|
76
|
+
message: PendingTranscriptInput,
|
|
71
77
|
index: number,
|
|
72
78
|
): TranscriptViewMessage {
|
|
73
79
|
return {
|
|
@@ -77,6 +83,7 @@ export function pendingTranscriptMessage(
|
|
|
77
83
|
? [{ type: "text", redacted: true }]
|
|
78
84
|
: [{ type: "text", text: message.text ?? "" }],
|
|
79
85
|
...(message.attachments ? { attachments: message.attachments } : undefined),
|
|
86
|
+
...(message.images?.length ? { images: message.images } : undefined),
|
|
80
87
|
pending: true,
|
|
81
88
|
role: "user",
|
|
82
89
|
source: message.source,
|
|
@@ -97,7 +104,7 @@ export function pendingTranscriptMessage(
|
|
|
97
104
|
*/
|
|
98
105
|
export function mergePendingTranscriptMessages(
|
|
99
106
|
messages: TranscriptViewMessage[],
|
|
100
|
-
pending: readonly
|
|
107
|
+
pending: readonly PendingTranscriptInput[] | undefined,
|
|
101
108
|
): TranscriptViewMessage[] {
|
|
102
109
|
if (!pending?.length) return messages;
|
|
103
110
|
const extras = unresolvedPendingTranscriptMessages(messages, pending);
|
|
@@ -112,7 +119,7 @@ export function mergePendingTranscriptMessages(
|
|
|
112
119
|
*/
|
|
113
120
|
export function unresolvedPendingTranscriptMessages(
|
|
114
121
|
messages: readonly TranscriptViewMessage[],
|
|
115
|
-
pending: readonly
|
|
122
|
+
pending: readonly PendingTranscriptInput[] | undefined,
|
|
116
123
|
): TranscriptViewMessage[] {
|
|
117
124
|
if (!pending?.length) return [];
|
|
118
125
|
const committedIds = historyMessageIds(messages);
|
|
@@ -124,7 +131,7 @@ export function unresolvedPendingTranscriptMessages(
|
|
|
124
131
|
/** Reduce the ordered reporting event API into dashboard-only transcript rows. */
|
|
125
132
|
export function conversationTranscriptMessages(
|
|
126
133
|
conversation: ConversationTranscript,
|
|
127
|
-
pendingMessages?: readonly
|
|
134
|
+
pendingMessages?: readonly PendingTranscriptInput[],
|
|
128
135
|
): TranscriptViewMessage[] {
|
|
129
136
|
return transcriptMessagesFromEvents(
|
|
130
137
|
conversation.events,
|
|
@@ -137,7 +144,7 @@ export function conversationTranscriptMessages(
|
|
|
137
144
|
export function transcriptMessagesFromEvents(
|
|
138
145
|
events: ConversationReportEvent[],
|
|
139
146
|
annotations: ConversationTranscript["annotations"],
|
|
140
|
-
pendingMessages?: readonly
|
|
147
|
+
pendingMessages?: readonly PendingTranscriptInput[],
|
|
141
148
|
): TranscriptViewMessage[] {
|
|
142
149
|
const replacedToolIds = specialToolIds(events);
|
|
143
150
|
const tools = new Map<
|
|
@@ -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) {
|