@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.
Files changed (38) hide show
  1. package/dist/app.js +38 -2
  2. package/dist/client.js +81 -82
  3. package/dist/index.js +38 -2
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +4 -4
  6. package/src/client/App.tsx +20 -0
  7. package/src/client/components/ParticipantAvatarStack.tsx +2 -1
  8. package/src/client/components/README.md +4 -0
  9. package/src/client/components/UserInputForm.tsx +283 -0
  10. package/src/client/components/layout/DashboardHeader.tsx +13 -0
  11. package/src/client/components/ui/button.tsx +11 -1
  12. package/src/client/conversations/ConversationHomeList.tsx +6 -0
  13. package/src/client/conversations/ConversationListStatusIcon.tsx +15 -1
  14. package/src/client/conversations/ConversationPage.tsx +4 -0
  15. package/src/client/conversations/ConversationSidebar.tsx +6 -0
  16. package/src/client/conversations/ConversationWorkspace.tsx +4 -0
  17. package/src/client/conversations/TranscriptActivityGroup.tsx +5 -0
  18. package/src/client/conversations/TranscriptMessageContextView.tsx +12 -1
  19. package/src/client/conversations/TranscriptMessageView.tsx +3 -0
  20. package/src/client/conversations/TranscriptToolView.tsx +13 -1
  21. package/src/client/conversations/TranscriptTurnContextView.tsx +142 -144
  22. package/src/client/conversations/UserInputRequestCard.tsx +14 -219
  23. package/src/client/conversations/turnContext.ts +15 -0
  24. package/src/client/format.ts +21 -5
  25. package/src/client/pages/SettingsPage.tsx +103 -3
  26. package/src/client/pages/code/RepositorySections.tsx +1 -0
  27. package/src/client/pages/code/RepositoryTabs.tsx +3 -0
  28. package/src/client/primaryNav.ts +15 -2
  29. package/src/client/review-desk/ConversationReviewBar.tsx +62 -0
  30. package/src/client/review-desk/ReviewDeskPage.tsx +277 -0
  31. package/src/client/review-desk/ReviewItemActions.tsx +165 -0
  32. package/src/client/review-desk/ReviewItemCard.tsx +47 -0
  33. package/src/client/review-desk/ReviewItemDetail.tsx +115 -0
  34. package/src/client/review-desk/queries.ts +164 -0
  35. package/src/client/review-desk/reviewItemFormat.ts +61 -0
  36. package/src/mock-reporting/fixtures.ts +10 -0
  37. package/src/mock-reporting/routes.ts +20 -0
  38. 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
- return <div className="min-w-0">{frame}</div>;
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 { Brain, Braces, ChevronRight, Layers } from "lucide-react";
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, formatTime } from "../format";
22
- import { memoryRecallContent, type MemoryRecallContent } from "./turnContext";
23
- import { cn, dashboardDisclosureChevronClass } from "../styles";
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
- /** The turn context that a page panel shows, keyed by its message. */
28
- export type TurnContextSelection = {
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
- id: string;
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(props: {
65
- contexts: TranscriptViewTurnContext[];
66
- messageId?: string;
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({ contexts: props.contexts, id });
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
- <TurnContextList contexts={props.contexts} />
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="px-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
145
- <TurnContextList contexts={props.contexts} />
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
- function TurnContextList(props: { contexts: TranscriptViewTurnContext[] }) {
152
- return props.contexts.map((context, index) => (
153
- <TurnContext
154
- context={context}
155
- key={`${context.pluginName}:${context.kind}:${context.version}:${index}`}
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 TurnContext(props: { context: TranscriptViewTurnContext }) {
161
- const memory = memoryRecallContent(props.context);
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 className="border-b border-dashboard-border-strong py-5 last:border-b-0">
165
- <div className="mb-4 flex items-start justify-between gap-4">
166
- <div className="flex min-w-0 items-center gap-2.5">
167
- {memory ? (
168
- <Brain
169
- aria-hidden="true"
170
- className="shrink-0 text-dashboard-accent-text/60"
171
- size={15}
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
- <Braces
175
- aria-hidden="true"
176
- className="shrink-0 text-dashboard-accent-text/60"
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
- <h3 className="m-0 truncate text-sm font-semibold text-dashboard-text">
181
- {memory ? "Recalled memories" : props.context.pluginName}
182
- </h3>
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
- <span className="shrink-0 text-xs text-dashboard-text-muted">
185
- {props.context.kind} · v{props.context.version}
186
- </span>
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 MemoryRecall(props: {
202
- loadedAt: string;
203
- memories: MemoryRecallContent["memories"];
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
- <div className="overflow-hidden rounded-lg border border-dashboard-border-strong">
207
- {props.memories.map((memory, index) => (
208
- <Collapsible
209
- className="border-t border-dashboard-border-strong first:border-t-0"
210
- key={memory.id}
211
- >
212
- <TranscriptSummaryTrigger className="group flex items-start gap-2.5">
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
- function GenericContext(props: { context: TranscriptViewTurnContext }) {
273
+ // Each row links to the memory's page, which shows its details.
274
+ function MemoryRecall(props: { memories: MemoryRecallContent["memories"] }) {
281
275
  return (
282
- <div>
283
- <pre className="m-0 overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-dashboard-fill-soft p-3 text-xs leading-relaxed text-dashboard-text-muted">
284
- <HighlightText text={JSON.stringify(props.context.content, null, 2)} />
285
- </pre>
286
- <p className="m-0 mt-3 text-xs text-dashboard-text-muted">
287
- Loaded {formatMessageTimestamp(Date.parse(props.context.loadedAt))}
288
- </p>
289
- </div>
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 { 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";
1
+ import { formatUserInputAnswer } from "@sentry/junior/api/schema";
10
2
 
11
- import { Button } from "../components/ui/button";
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. 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.
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
- {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}
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
  }