@sentry/junior-dashboard 0.267.0 → 0.268.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 +38 -2
- package/dist/client.js +81 -82
- package/dist/index.js +38 -2
- package/dist/tailwind.css +1 -1
- package/package.json +4 -4
- package/src/client/App.tsx +20 -0
- package/src/client/components/ParticipantAvatarStack.tsx +2 -1
- package/src/client/components/README.md +4 -0
- package/src/client/components/UserInputForm.tsx +283 -0
- package/src/client/components/layout/DashboardHeader.tsx +13 -0
- package/src/client/components/ui/button.tsx +11 -1
- package/src/client/conversations/ConversationHomeList.tsx +6 -0
- package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
- package/src/client/conversations/ConversationPage.tsx +4 -0
- package/src/client/conversations/ConversationSidebar.tsx +6 -0
- package/src/client/conversations/ConversationWorkspace.tsx +4 -0
- package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
- package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
- package/src/client/conversations/TranscriptMessageView.tsx +3 -0
- package/src/client/conversations/TranscriptToolView.tsx +13 -1
- package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
- package/src/client/conversations/UserInputRequestCard.tsx +14 -219
- package/src/client/conversations/turnContext.ts +15 -0
- package/src/client/format.ts +21 -5
- package/src/client/pages/SettingsPage.tsx +103 -3
- package/src/client/pages/code/RepositorySections.tsx +1 -0
- package/src/client/pages/code/RepositoryTabs.tsx +3 -0
- package/src/client/primaryNav.ts +15 -2
- package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
- package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
- package/src/client/review-desk/ReviewItemActions.tsx +165 -0
- package/src/client/review-desk/ReviewItemCard.tsx +47 -0
- package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
- package/src/client/review-desk/queries.ts +164 -0
- package/src/client/review-desk/reviewItemFormat.ts +61 -0
- package/src/mock-reporting/fixtures.ts +10 -0
- package/src/mock-reporting/routes.ts +20 -0
- package/src/shell.ts +3 -0
|
@@ -9,6 +9,7 @@ import { memo, type ReactNode } from "react";
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
formatMessageTimestamp,
|
|
12
|
+
transcriptMessageActor,
|
|
12
13
|
transcriptMessageActorLabel,
|
|
13
14
|
transcriptRoleKind,
|
|
14
15
|
} from "../format";
|
|
@@ -130,8 +131,10 @@ function messageAction(
|
|
|
130
131
|
if (message.role === "user" && message.contexts?.length) {
|
|
131
132
|
return (
|
|
132
133
|
<TranscriptTurnContextView
|
|
134
|
+
actor={transcriptMessageActor(conversation, message)}
|
|
133
135
|
contexts={message.contexts}
|
|
134
136
|
messageId={message.messageId}
|
|
137
|
+
timestamp={message.timestamp}
|
|
135
138
|
/>
|
|
136
139
|
);
|
|
137
140
|
}
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
} from "../format";
|
|
11
11
|
import type { TranscriptViewToolCallPart } from "../types";
|
|
12
12
|
import { ShimmerText } from "../components/ShimmerText";
|
|
13
|
+
import { TranscriptReviewItemCard } from "../review-desk/ReviewItemCard";
|
|
14
|
+
import { reviewItemIdFromToolCall } from "../review-desk/reviewItemFormat";
|
|
13
15
|
import { cn } from "../styles";
|
|
14
16
|
import { ToolFrame } from "./ToolFrame";
|
|
15
17
|
import { toolCallPreview } from "./toolCallPreview";
|
|
@@ -64,7 +66,17 @@ export const TranscriptToolView = memo(function TranscriptToolView(props: {
|
|
|
64
66
|
</ToolFrame>
|
|
65
67
|
);
|
|
66
68
|
|
|
67
|
-
|
|
69
|
+
const reviewItemId = reviewItemIdFromToolCall(props.part);
|
|
70
|
+
return (
|
|
71
|
+
<div className="min-w-0">
|
|
72
|
+
{frame}
|
|
73
|
+
{reviewItemId ? (
|
|
74
|
+
<div className="mt-2">
|
|
75
|
+
<TranscriptReviewItemCard itemId={reviewItemId} />
|
|
76
|
+
</div>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
68
80
|
});
|
|
69
81
|
|
|
70
82
|
function ToolSignature(props: {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { ActorIdentity } from "@sentry/junior/api/schema";
|
|
2
|
+
import { Layers } from "lucide-react";
|
|
2
3
|
import {
|
|
3
4
|
createContext,
|
|
4
5
|
useContext,
|
|
@@ -7,10 +8,12 @@ import {
|
|
|
7
8
|
type ReactNode,
|
|
8
9
|
} from "react";
|
|
9
10
|
import { createPortal } from "react-dom";
|
|
11
|
+
import { Link } from "react-router";
|
|
10
12
|
|
|
13
|
+
import { ActorAvatar } from "../components/ActorAvatar";
|
|
11
14
|
import { DetailsPanel } from "../components/DetailsPanel";
|
|
15
|
+
import { participantName } from "../components/ParticipantAvatarStack";
|
|
12
16
|
import { Button } from "../components/ui/button";
|
|
13
|
-
import { Collapsible, CollapsibleContent } from "../components/ui/collapsible";
|
|
14
17
|
import { Drawer } from "../components/Drawer";
|
|
15
18
|
import {
|
|
16
19
|
Tooltip,
|
|
@@ -18,18 +21,26 @@ import {
|
|
|
18
21
|
TooltipTrigger,
|
|
19
22
|
} from "../components/ui/tooltip";
|
|
20
23
|
import type { TranscriptViewTurnContext } from "../types";
|
|
21
|
-
import { formatMessageTimestamp,
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
+
import { formatMessageTimestamp, memoryPath, peoplePath } from "../format";
|
|
25
|
+
import {
|
|
26
|
+
memoryRecallContent,
|
|
27
|
+
userCustomizationContent,
|
|
28
|
+
type MemoryRecallContent,
|
|
29
|
+
} from "./turnContext";
|
|
30
|
+
import { cn } from "../styles";
|
|
31
|
+
import { DetailsCard } from "./DetailsList";
|
|
24
32
|
import { HighlightText } from "./transcriptSearch";
|
|
25
|
-
import { TranscriptSummaryTrigger } from "./TranscriptSummary";
|
|
26
33
|
|
|
27
|
-
/**
|
|
28
|
-
export type
|
|
34
|
+
/** What one turn context view shows: who spoke, when, and the context. */
|
|
35
|
+
export type TurnContext = {
|
|
36
|
+
actor?: ActorIdentity;
|
|
29
37
|
contexts: TranscriptViewTurnContext[];
|
|
30
|
-
|
|
38
|
+
timestamp?: number;
|
|
31
39
|
};
|
|
32
40
|
|
|
41
|
+
/** The turn context that a page panel shows, keyed by its message. */
|
|
42
|
+
export type TurnContextSelection = TurnContext & { id: string };
|
|
43
|
+
|
|
33
44
|
/** Let the page that owns a panel show turn context there. */
|
|
34
45
|
export type TurnContextPanel = {
|
|
35
46
|
openId?: string;
|
|
@@ -61,10 +72,9 @@ export function TurnContextPanelProvider(props: {
|
|
|
61
72
|
* keys context by `messageId`, so every control for one message, including a
|
|
62
73
|
* remounted one, shows the same open state.
|
|
63
74
|
*/
|
|
64
|
-
export function TranscriptTurnContextView(
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}) {
|
|
75
|
+
export function TranscriptTurnContextView(
|
|
76
|
+
props: TurnContext & { messageId?: string },
|
|
77
|
+
) {
|
|
68
78
|
const panel = useContext(TurnContextPanelContext);
|
|
69
79
|
const [open, setOpen] = useState(false);
|
|
70
80
|
const instanceId = useId();
|
|
@@ -90,7 +100,12 @@ export function TranscriptTurnContextView(props: {
|
|
|
90
100
|
data-turn-context-id={id}
|
|
91
101
|
onClick={() => {
|
|
92
102
|
if (!panel) return setOpen(true);
|
|
93
|
-
panel.onToggle({
|
|
103
|
+
panel.onToggle({
|
|
104
|
+
actor: props.actor,
|
|
105
|
+
contexts: props.contexts,
|
|
106
|
+
id,
|
|
107
|
+
timestamp: props.timestamp,
|
|
108
|
+
});
|
|
94
109
|
}}
|
|
95
110
|
size="icon"
|
|
96
111
|
>
|
|
@@ -117,7 +132,11 @@ export function TranscriptTurnContextView(props: {
|
|
|
117
132
|
openKey={instanceId}
|
|
118
133
|
titleId={instanceId}
|
|
119
134
|
>
|
|
120
|
-
<
|
|
135
|
+
<TurnContextBody
|
|
136
|
+
actor={props.actor}
|
|
137
|
+
contexts={props.contexts}
|
|
138
|
+
timestamp={props.timestamp}
|
|
139
|
+
/>
|
|
121
140
|
</Drawer>,
|
|
122
141
|
document.body,
|
|
123
142
|
)
|
|
@@ -127,10 +146,7 @@ export function TranscriptTurnContextView(props: {
|
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
/** Show turn context in the page panel: an aside, or a sheet on small screens. */
|
|
130
|
-
export function TurnContextDetails(props: {
|
|
131
|
-
contexts: TranscriptViewTurnContext[];
|
|
132
|
-
onClose(): void;
|
|
133
|
-
}) {
|
|
149
|
+
export function TurnContextDetails(props: TurnContext & { onClose(): void }) {
|
|
134
150
|
return (
|
|
135
151
|
<DetailsPanel
|
|
136
152
|
label="Turn context"
|
|
@@ -141,151 +157,133 @@ export function TurnContextDetails(props: {
|
|
|
141
157
|
</h2>
|
|
142
158
|
}
|
|
143
159
|
>
|
|
144
|
-
<div className="
|
|
145
|
-
<
|
|
160
|
+
<div className="p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
|
161
|
+
<TurnContextBody
|
|
162
|
+
actor={props.actor}
|
|
163
|
+
contexts={props.contexts}
|
|
164
|
+
timestamp={props.timestamp}
|
|
165
|
+
/>
|
|
146
166
|
</div>
|
|
147
167
|
</DetailsPanel>
|
|
148
168
|
);
|
|
149
169
|
}
|
|
150
170
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
)
|
|
171
|
+
/** Show who spoke, then their customization, then plugin context. */
|
|
172
|
+
function TurnContextBody(props: TurnContext) {
|
|
173
|
+
const contexts = [...props.contexts].sort(
|
|
174
|
+
(a, b) =>
|
|
175
|
+
Number(Boolean(userCustomizationContent(b))) -
|
|
176
|
+
Number(Boolean(userCustomizationContent(a))),
|
|
177
|
+
);
|
|
178
|
+
return (
|
|
179
|
+
<div className="grid min-w-0 gap-3">
|
|
180
|
+
{props.actor ? (
|
|
181
|
+
<TurnContextActor actor={props.actor} timestamp={props.timestamp} />
|
|
182
|
+
) : null}
|
|
183
|
+
{contexts.map((context, index) => (
|
|
184
|
+
<TurnContextCard
|
|
185
|
+
context={context}
|
|
186
|
+
key={`${context.pluginName}:${context.kind}:${context.version}:${index}`}
|
|
187
|
+
/>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
158
191
|
}
|
|
159
192
|
|
|
160
|
-
function
|
|
161
|
-
const
|
|
162
|
-
|
|
193
|
+
function TurnContextActor(props: { actor: ActorIdentity; timestamp?: number }) {
|
|
194
|
+
const name = participantName(props.actor);
|
|
195
|
+
const email = props.actor.email?.trim();
|
|
196
|
+
const handle = props.actor.slackUserName?.trim();
|
|
197
|
+
const meta = [
|
|
198
|
+
email && email !== name ? email : undefined,
|
|
199
|
+
handle && handle !== name ? `@${handle}` : undefined,
|
|
200
|
+
].filter(Boolean);
|
|
163
201
|
return (
|
|
164
|
-
<section
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
202
|
+
<section
|
|
203
|
+
aria-label="Speaker"
|
|
204
|
+
className="flex min-w-0 items-center gap-3 rounded-lg border border-dashboard-border-subtle bg-dashboard-surface-panel p-3"
|
|
205
|
+
>
|
|
206
|
+
<ActorAvatar name={name} size="detail" />
|
|
207
|
+
<div className="min-w-0 flex-1">
|
|
208
|
+
<div className="flex min-w-0 items-baseline justify-between gap-3">
|
|
209
|
+
{email ? (
|
|
210
|
+
<Link
|
|
211
|
+
className="min-w-0 truncate text-sm font-medium text-dashboard-text no-underline hover:underline"
|
|
212
|
+
to={peoplePath(email)}
|
|
213
|
+
>
|
|
214
|
+
{name}
|
|
215
|
+
</Link>
|
|
173
216
|
) : (
|
|
174
|
-
<
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
size={15}
|
|
178
|
-
/>
|
|
217
|
+
<span className="min-w-0 truncate text-sm font-medium text-dashboard-text">
|
|
218
|
+
{name}
|
|
219
|
+
</span>
|
|
179
220
|
)}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
221
|
+
{props.timestamp !== undefined ? (
|
|
222
|
+
<time
|
|
223
|
+
className="shrink-0 text-xs tabular-nums text-dashboard-text-muted"
|
|
224
|
+
dateTime={new Date(props.timestamp).toISOString()}
|
|
225
|
+
>
|
|
226
|
+
{formatMessageTimestamp(props.timestamp, true)}
|
|
227
|
+
</time>
|
|
228
|
+
) : null}
|
|
183
229
|
</div>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
230
|
+
{meta.length ? (
|
|
231
|
+
<p
|
|
232
|
+
className="m-0 mt-0.5 truncate text-xs text-dashboard-text-muted"
|
|
233
|
+
title={props.actor.slackUserId}
|
|
234
|
+
>
|
|
235
|
+
{meta.join(" · ")}
|
|
236
|
+
</p>
|
|
237
|
+
) : null}
|
|
187
238
|
</div>
|
|
188
|
-
|
|
189
|
-
{memory ? (
|
|
190
|
-
<MemoryRecall
|
|
191
|
-
loadedAt={props.context.loadedAt}
|
|
192
|
-
memories={memory.memories}
|
|
193
|
-
/>
|
|
194
|
-
) : (
|
|
195
|
-
<GenericContext context={props.context} />
|
|
196
|
-
)}
|
|
197
239
|
</section>
|
|
198
240
|
);
|
|
199
241
|
}
|
|
200
242
|
|
|
201
|
-
function
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
243
|
+
function TurnContextCard(props: { context: TranscriptViewTurnContext }) {
|
|
244
|
+
const memory = memoryRecallContent(props.context);
|
|
245
|
+
if (memory) {
|
|
246
|
+
return (
|
|
247
|
+
<DetailsCard count={memory.memories.length} title="Recalled memories">
|
|
248
|
+
<MemoryRecall memories={memory.memories} />
|
|
249
|
+
</DetailsCard>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const customization = userCustomizationContent(props.context);
|
|
253
|
+
if (customization) {
|
|
254
|
+
return (
|
|
255
|
+
<DetailsCard title="User customization">
|
|
256
|
+
<p className="m-0 whitespace-pre-wrap text-sm leading-relaxed text-dashboard-text">
|
|
257
|
+
<HighlightText text={customization.text} />
|
|
258
|
+
</p>
|
|
259
|
+
</DetailsCard>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
205
262
|
return (
|
|
206
|
-
<
|
|
207
|
-
{props.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
<ChevronRight
|
|
214
|
-
aria-hidden="true"
|
|
215
|
-
className={cn(
|
|
216
|
-
dashboardDisclosureChevronClass,
|
|
217
|
-
"mt-0.5 text-dashboard-text-muted",
|
|
218
|
-
)}
|
|
219
|
-
/>
|
|
220
|
-
<span className="min-w-0 flex-1">
|
|
221
|
-
<span className="flex items-center justify-between gap-3">
|
|
222
|
-
<span className="text-xs font-medium text-dashboard-text-muted">
|
|
223
|
-
Memory {index + 1}
|
|
224
|
-
</span>
|
|
225
|
-
<span className="shrink-0 text-xs text-dashboard-text-muted">
|
|
226
|
-
{memory.kind} · {memory.scope}
|
|
227
|
-
</span>
|
|
228
|
-
</span>
|
|
229
|
-
<span className="mt-1 block truncate text-sm text-dashboard-text-muted">
|
|
230
|
-
<HighlightText text={memory.content} />
|
|
231
|
-
</span>
|
|
232
|
-
</span>
|
|
233
|
-
</TranscriptSummaryTrigger>
|
|
234
|
-
|
|
235
|
-
<CollapsibleContent hiddenUntilFound>
|
|
236
|
-
<div className="border-t border-dashboard-border bg-dashboard-fill-faint px-4 py-4">
|
|
237
|
-
<div className="whitespace-pre-wrap text-sm leading-6 text-dashboard-text">
|
|
238
|
-
<HighlightText text={memory.content} />
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
<dl className="mt-4 grid gap-2 text-xs">
|
|
242
|
-
<div className="grid grid-cols-[5rem_minmax(0,1fr)] items-start gap-3">
|
|
243
|
-
<dt className="text-dashboard-text-muted">Memory ID</dt>
|
|
244
|
-
<dd className="m-0 break-all font-mono text-dashboard-text-muted">
|
|
245
|
-
<HighlightText text={memory.id} />
|
|
246
|
-
</dd>
|
|
247
|
-
</div>
|
|
248
|
-
<div className="grid grid-cols-[5rem_minmax(0,1fr)] items-start gap-3">
|
|
249
|
-
<dt className="text-dashboard-text-muted">Observed</dt>
|
|
250
|
-
<dd className="m-0 text-dashboard-text-muted">
|
|
251
|
-
{formatTime(new Date(memory.observedAtMs).toISOString())}
|
|
252
|
-
</dd>
|
|
253
|
-
</div>
|
|
254
|
-
<div className="grid grid-cols-[5rem_minmax(0,1fr)] items-start gap-3">
|
|
255
|
-
<dt className="text-dashboard-text-muted">Scope</dt>
|
|
256
|
-
<dd className="m-0 text-dashboard-text-muted">
|
|
257
|
-
{memory.scope}
|
|
258
|
-
</dd>
|
|
259
|
-
</div>
|
|
260
|
-
<div className="grid grid-cols-[5rem_minmax(0,1fr)] items-start gap-3">
|
|
261
|
-
<dt className="text-dashboard-text-muted">Kind</dt>
|
|
262
|
-
<dd className="m-0 text-dashboard-text-muted">
|
|
263
|
-
{memory.kind}
|
|
264
|
-
</dd>
|
|
265
|
-
</div>
|
|
266
|
-
</dl>
|
|
267
|
-
</div>
|
|
268
|
-
</CollapsibleContent>
|
|
269
|
-
</Collapsible>
|
|
270
|
-
))}
|
|
271
|
-
<p className="m-0 border-t border-dashboard-border-strong px-3 py-2 text-xs text-dashboard-text-muted">
|
|
272
|
-
{props.memories.length}{" "}
|
|
273
|
-
{props.memories.length === 1 ? "memory" : "memories"} · Loaded{" "}
|
|
274
|
-
{formatMessageTimestamp(Date.parse(props.loadedAt))}
|
|
275
|
-
</p>
|
|
276
|
-
</div>
|
|
263
|
+
<DetailsCard
|
|
264
|
+
title={`${props.context.pluginName} · ${props.context.kind} v${props.context.version}`}
|
|
265
|
+
>
|
|
266
|
+
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">
|
|
267
|
+
<HighlightText text={JSON.stringify(props.context.content, null, 2)} />
|
|
268
|
+
</pre>
|
|
269
|
+
</DetailsCard>
|
|
277
270
|
);
|
|
278
271
|
}
|
|
279
272
|
|
|
280
|
-
|
|
273
|
+
// Each row links to the memory's page, which shows its details.
|
|
274
|
+
function MemoryRecall(props: { memories: MemoryRecallContent["memories"] }) {
|
|
281
275
|
return (
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
276
|
+
<ul className="-mx-1.5 -mb-1.5 -mt-1 grid list-none gap-0.5 p-0">
|
|
277
|
+
{props.memories.map((memory) => (
|
|
278
|
+
<li key={memory.id}>
|
|
279
|
+
<Link
|
|
280
|
+
className="line-clamp-2 rounded-md px-1.5 py-1.5 text-sm leading-5 text-dashboard-text no-underline transition-colors hover:bg-dashboard-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-dashboard-focus"
|
|
281
|
+
to={memoryPath(memory.id)}
|
|
282
|
+
>
|
|
283
|
+
<HighlightText text={memory.content} />
|
|
284
|
+
</Link>
|
|
285
|
+
</li>
|
|
286
|
+
))}
|
|
287
|
+
</ul>
|
|
290
288
|
);
|
|
291
289
|
}
|
|
@@ -1,150 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
buttonUserInputField,
|
|
4
|
-
formatUserInputAnswer,
|
|
5
|
-
isEmptyUserInputAnswer,
|
|
6
|
-
normalizeUserInputAnswer,
|
|
7
|
-
type UserInputAnswer,
|
|
8
|
-
type UserInputField,
|
|
9
|
-
} from "@sentry/junior/api/schema";
|
|
1
|
+
import { formatUserInputAnswer } from "@sentry/junior/api/schema";
|
|
10
2
|
|
|
11
|
-
import {
|
|
12
|
-
import { Textarea } from "../components/ui/textarea";
|
|
3
|
+
import { UserInputForm } from "../components/UserInputForm";
|
|
13
4
|
import { TranscriptText } from "./TranscriptText";
|
|
14
5
|
import type { OpenUserInputRequest } from "./userInputRequest";
|
|
15
6
|
|
|
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
7
|
/**
|
|
101
|
-
* Show Junior's open request above the composer.
|
|
102
|
-
*
|
|
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.
|
|
8
|
+
* Show Junior's open request above the composer. The answer goes as the
|
|
9
|
+
* reader's next message. Typing a reply also answers it.
|
|
105
10
|
*/
|
|
106
11
|
export function UserInputRequestCard(props: {
|
|
107
12
|
onAnswer(answer: string): Promise<void>;
|
|
108
13
|
request: OpenUserInputRequest;
|
|
109
14
|
}) {
|
|
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
15
|
return (
|
|
149
16
|
<section
|
|
150
17
|
aria-label="Junior asks"
|
|
@@ -157,88 +24,16 @@ export function UserInputRequestCard(props: {
|
|
|
157
24
|
text={props.request.question}
|
|
158
25
|
/>
|
|
159
26
|
</div>
|
|
160
|
-
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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}
|
|
27
|
+
{/* Above the composer, a long form scrolls so the transcript stays visible. */}
|
|
28
|
+
<div className="max-h-96 overflow-y-auto">
|
|
29
|
+
<UserInputForm
|
|
30
|
+
onSubmit={(answer) =>
|
|
31
|
+
props.onAnswer(formatUserInputAnswer(props.request, answer))
|
|
32
|
+
}
|
|
33
|
+
request={props.request}
|
|
34
|
+
requestId={props.request.toolCallId}
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
242
37
|
</section>
|
|
243
38
|
);
|
|
244
39
|
}
|