@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
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { ForkConversationButton } from "./ForkConversationButton";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
TranscriptAttachments,
|
|
4
|
+
TranscriptLocalImages,
|
|
5
|
+
} from "./TranscriptAttachment";
|
|
3
6
|
import { ObjectCard } from "./ObjectCard";
|
|
4
7
|
import { AutomationCard } from "../components/AutomationCard";
|
|
5
8
|
import { memo, type ReactNode } from "react";
|
|
@@ -69,6 +72,9 @@ export const TranscriptMessageView = memo(
|
|
|
69
72
|
conversationId={props.conversation.conversationId}
|
|
70
73
|
/>
|
|
71
74
|
) : null}
|
|
75
|
+
{props.message.images?.length ? (
|
|
76
|
+
<TranscriptLocalImages images={props.message.images} />
|
|
77
|
+
) : null}
|
|
72
78
|
{props.message.cards?.map((card) => {
|
|
73
79
|
switch (card.kind) {
|
|
74
80
|
case "object":
|
|
@@ -1,37 +1,12 @@
|
|
|
1
|
-
import type { ComponentProps
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
2
|
|
|
3
3
|
import { CollapsibleTrigger } from "../components/ui/collapsible";
|
|
4
4
|
import { cn } from "../styles";
|
|
5
5
|
|
|
6
|
-
const summaryClass =
|
|
7
|
-
"cursor-pointer rounded-md px-2 py-1.5 transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus";
|
|
8
|
-
|
|
9
|
-
/** Keep hover and keyboard focus around a padded transcript cell. */
|
|
10
|
-
export function TranscriptSummary({
|
|
11
|
-
className,
|
|
12
|
-
flush = false,
|
|
13
|
-
...props
|
|
14
|
-
}: ComponentPropsWithoutRef<"summary"> & {
|
|
15
|
-
/** Extend padding around text that already sits on the transcript edge. */
|
|
16
|
-
flush?: boolean;
|
|
17
|
-
}) {
|
|
18
|
-
return (
|
|
19
|
-
<summary
|
|
20
|
-
{...props}
|
|
21
|
-
className={cn(
|
|
22
|
-
summaryClass,
|
|
23
|
-
"list-none [&::-webkit-details-marker]:hidden",
|
|
24
|
-
flush && "-mx-2 -my-1.5",
|
|
25
|
-
className,
|
|
26
|
-
)}
|
|
27
|
-
/>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
6
|
/**
|
|
32
|
-
* Give a `Collapsible` trigger the
|
|
33
|
-
*
|
|
34
|
-
* such as `span`, inside the trigger.
|
|
7
|
+
* Give a `Collapsible` trigger the padding, hover, and keyboard focus of a
|
|
8
|
+
* transcript disclosure. The caller sets the display. Put only inline
|
|
9
|
+
* elements, such as `span`, inside the trigger.
|
|
35
10
|
*/
|
|
36
11
|
export function TranscriptSummaryTrigger({
|
|
37
12
|
className,
|
|
@@ -46,8 +21,7 @@ export function TranscriptSummaryTrigger({
|
|
|
46
21
|
<CollapsibleTrigger
|
|
47
22
|
{...props}
|
|
48
23
|
className={cn(
|
|
49
|
-
|
|
50
|
-
"text-left disabled:cursor-default disabled:hover:bg-transparent",
|
|
24
|
+
"cursor-pointer rounded-md px-2 py-1.5 text-left transition-colors hover:bg-dashboard-fill-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus disabled:cursor-default disabled:hover:bg-transparent",
|
|
51
25
|
flush ? "-mx-2 -my-1.5 w-[calc(100%+1rem)]" : "w-full",
|
|
52
26
|
className,
|
|
53
27
|
)}
|
|
@@ -9,8 +9,14 @@ import {
|
|
|
9
9
|
import { createPortal } from "react-dom";
|
|
10
10
|
|
|
11
11
|
import { DetailsPanel } from "../components/DetailsPanel";
|
|
12
|
+
import { Button } from "../components/ui/button";
|
|
12
13
|
import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
|
|
13
14
|
import { Drawer } from "../components/Drawer";
|
|
15
|
+
import {
|
|
16
|
+
Tooltip,
|
|
17
|
+
TooltipContent,
|
|
18
|
+
TooltipTrigger,
|
|
19
|
+
} from "../components/ui/tooltip";
|
|
14
20
|
import type { TranscriptViewTurnContext } from "../types";
|
|
15
21
|
import { formatMessageTimestamp, formatTime } from "../format";
|
|
16
22
|
import { memoryRecallContent, type MemoryRecallContent } from "./turnContext";
|
|
@@ -67,24 +73,33 @@ export function TranscriptTurnContextView(props: {
|
|
|
67
73
|
|
|
68
74
|
return (
|
|
69
75
|
<>
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
76
|
+
<Tooltip>
|
|
77
|
+
<TooltipTrigger
|
|
78
|
+
// The open panel keeps focus here. Close the tooltip on click so
|
|
79
|
+
// Escape reaches the panel.
|
|
80
|
+
closeOnClick
|
|
81
|
+
render={
|
|
82
|
+
<Button
|
|
83
|
+
aria-haspopup={panel ? undefined : "dialog"}
|
|
84
|
+
aria-expanded={expanded}
|
|
85
|
+
aria-label="View turn context"
|
|
86
|
+
className={cn(
|
|
87
|
+
"size-6 shrink-0",
|
|
88
|
+
expanded && "bg-dashboard-fill-strong text-dashboard-text",
|
|
89
|
+
)}
|
|
90
|
+
data-turn-context-id={id}
|
|
91
|
+
onClick={() => {
|
|
92
|
+
if (!panel) return setOpen(true);
|
|
93
|
+
panel.onToggle({ contexts: props.contexts, id });
|
|
94
|
+
}}
|
|
95
|
+
size="icon"
|
|
96
|
+
>
|
|
97
|
+
<Layers aria-hidden="true" size={14} strokeWidth={1.8} />
|
|
98
|
+
</Button>
|
|
99
|
+
}
|
|
100
|
+
/>
|
|
101
|
+
<TooltipContent size="compact">View turn context</TooltipContent>
|
|
102
|
+
</Tooltip>
|
|
88
103
|
|
|
89
104
|
{!panel && open
|
|
90
105
|
? createPortal(
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
+
import type { ConversationMailboxMessage } from "./conversationOutbox";
|
|
2
3
|
import { ArrowDownToLine } from "lucide-react";
|
|
3
4
|
|
|
4
5
|
import type {
|
|
5
6
|
ConversationTranscript,
|
|
6
7
|
TranscriptViewSubagentPart,
|
|
7
8
|
} from "../types";
|
|
9
|
+
import { EmptyTelemetry } from "../components/EmptyTelemetry";
|
|
8
10
|
import { Button } from "../components/ui/button";
|
|
9
11
|
import {
|
|
10
12
|
Tooltip,
|
|
@@ -20,7 +22,6 @@ import {
|
|
|
20
22
|
usePinnedTranscriptBottom,
|
|
21
23
|
} from "./transcriptBottomPinning";
|
|
22
24
|
import type { TranscriptViewMode } from "./transcriptRenderModel";
|
|
23
|
-
import { transcriptEmptyClass } from "./transcriptStyles";
|
|
24
25
|
import { TranscriptSearchProvider } from "./transcriptSearch";
|
|
25
26
|
import {
|
|
26
27
|
TurnContextPanelProvider,
|
|
@@ -37,6 +38,8 @@ export function Transcript(props: {
|
|
|
37
38
|
pinRequestVersion?: number;
|
|
38
39
|
onLoadPreviousPage?: () => void;
|
|
39
40
|
onSelectedEventSeqChange?(seq: number | undefined): void;
|
|
41
|
+
/** The reader's own sends to show at the end until history has them. */
|
|
42
|
+
pendingMessages?: readonly ConversationMailboxMessage[];
|
|
40
43
|
responding?: boolean;
|
|
41
44
|
search?: string;
|
|
42
45
|
onSearchChange?(value: string): void;
|
|
@@ -86,9 +89,9 @@ export function Transcript(props: {
|
|
|
86
89
|
|
|
87
90
|
if (!props.transcript) {
|
|
88
91
|
return (
|
|
89
|
-
<
|
|
92
|
+
<EmptyTelemetry>
|
|
90
93
|
No transcript is available for this conversation.
|
|
91
|
-
</
|
|
94
|
+
</EmptyTelemetry>
|
|
92
95
|
);
|
|
93
96
|
}
|
|
94
97
|
|
|
@@ -139,6 +142,7 @@ export function Transcript(props: {
|
|
|
139
142
|
<ConversationTranscriptView
|
|
140
143
|
onOpenSubagentTranscript={props.onOpenSubagentTranscript}
|
|
141
144
|
conversation={props.transcript}
|
|
145
|
+
pendingMessages={props.pendingMessages}
|
|
142
146
|
responding={props.responding ?? props.live ?? false}
|
|
143
147
|
/>
|
|
144
148
|
</TurnContextPanelProvider>
|
|
@@ -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,
|
|
@@ -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<
|