@opengeni/react 0.36.1 → 0.40.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 (85) hide show
  1. package/README.md +92 -2
  2. package/dist/{chunk-J2RVJ6JX.js → chunk-4IJCL7YO.js} +13 -2
  3. package/dist/chunk-4IJCL7YO.js.map +1 -0
  4. package/dist/{chunk-7CJOAW3V.js → chunk-EB67J347.js} +1 -135
  5. package/dist/chunk-EB67J347.js.map +1 -0
  6. package/dist/chunk-EKZH6IDG.js +141 -0
  7. package/dist/chunk-EKZH6IDG.js.map +1 -0
  8. package/dist/{chunk-OKKMZDQF.js → chunk-JALF5FI3.js} +9 -7
  9. package/dist/{chunk-OKKMZDQF.js.map → chunk-JALF5FI3.js.map} +1 -1
  10. package/dist/{chunk-Z5FV355Z.js → chunk-KR2SK5GJ.js} +1382 -724
  11. package/dist/chunk-KR2SK5GJ.js.map +1 -0
  12. package/dist/{chunk-N4XEBE23.js → chunk-OMCFRWHL.js} +22 -13
  13. package/dist/{chunk-N4XEBE23.js.map → chunk-OMCFRWHL.js.map} +1 -1
  14. package/dist/{chunk-FT2TXNGZ.js → chunk-Q2NCKWTK.js} +183 -18
  15. package/dist/chunk-Q2NCKWTK.js.map +1 -0
  16. package/dist/{chunk-22RM4GMO.js → chunk-SJKT4TKW.js} +26 -2
  17. package/dist/chunk-SJKT4TKW.js.map +1 -0
  18. package/dist/{chunk-ALA3UGWB.js → chunk-SXIQK54Z.js} +8 -6
  19. package/dist/{chunk-ALA3UGWB.js.map → chunk-SXIQK54Z.js.map} +1 -1
  20. package/dist/chunk-WZT5G5OR.js +433 -0
  21. package/dist/chunk-WZT5G5OR.js.map +1 -0
  22. package/dist/client.d.ts +2 -0
  23. package/dist/components/chat-composer.d.ts +7 -1
  24. package/dist/components/human-input-form.d.ts +16 -6
  25. package/dist/components/human-input-surface.d.ts +19 -0
  26. package/dist/components/machine-input-display.d.ts +12 -0
  27. package/dist/components/message-timeline.d.ts +6 -1
  28. package/dist/components/model-policy-picker.d.ts +78 -0
  29. package/dist/components/session-chrome.d.ts +2 -1
  30. package/dist/components/tip-follow.d.ts +30 -0
  31. package/dist/composer.d.ts +2 -0
  32. package/dist/composer.js +22 -5
  33. package/dist/hooks/use-composer.d.ts +10 -1
  34. package/dist/index.d.ts +7 -1
  35. package/dist/index.js +2261 -2230
  36. package/dist/index.js.map +1 -1
  37. package/dist/machines.js +4 -3
  38. package/dist/model-policy.d.ts +12 -4
  39. package/dist/model-policy.js +5 -1
  40. package/dist/realtime/dropdown-menu.d.ts +16 -0
  41. package/dist/realtime/realtime-control.d.ts +126 -0
  42. package/dist/realtime.d.ts +11 -0
  43. package/dist/realtime.js +1023 -0
  44. package/dist/realtime.js.map +1 -0
  45. package/dist/session-context.d.ts +7 -1
  46. package/dist/session-ui.d.ts +2 -0
  47. package/dist/session-ui.js +6 -4
  48. package/dist/session.js +5 -4
  49. package/dist/timeline/compute-label.d.ts +2 -0
  50. package/dist/timeline/index.d.ts +1 -0
  51. package/dist/timeline/types.d.ts +5 -0
  52. package/package.json +7 -2
  53. package/src/client.ts +13 -0
  54. package/src/components/chat-composer.tsx +19 -5
  55. package/src/components/composer-transcription-control.tsx +2 -2
  56. package/src/components/composer.tsx +14 -5
  57. package/src/components/human-input-form.tsx +506 -186
  58. package/src/components/human-input-surface.tsx +80 -0
  59. package/src/components/machine-input-display.ts +83 -0
  60. package/src/components/message-timeline.tsx +348 -308
  61. package/src/components/model-policy-picker.tsx +587 -0
  62. package/src/components/sandbox-files.tsx +1 -1
  63. package/src/components/session-chrome.tsx +141 -15
  64. package/src/components/tip-follow.ts +127 -6
  65. package/src/composer.ts +13 -0
  66. package/src/hooks/use-composer.ts +229 -29
  67. package/src/index.ts +19 -0
  68. package/src/model-policy.ts +51 -7
  69. package/src/realtime/dropdown-menu.tsx +123 -0
  70. package/src/realtime/realtime-control.tsx +1178 -0
  71. package/src/realtime.ts +27 -0
  72. package/src/session-context.ts +16 -0
  73. package/src/session-ui.ts +2 -0
  74. package/src/timeline/compute-label.tsx +18 -0
  75. package/src/timeline/index.ts +3 -0
  76. package/src/timeline/projection.ts +21 -1
  77. package/src/timeline/tool-renderers.tsx +232 -43
  78. package/src/timeline/types.ts +5 -0
  79. package/styles/index.css +61 -0
  80. package/styles/tokens.css +2 -0
  81. package/dist/chunk-22RM4GMO.js.map +0 -1
  82. package/dist/chunk-7CJOAW3V.js.map +0 -1
  83. package/dist/chunk-FT2TXNGZ.js.map +0 -1
  84. package/dist/chunk-J2RVJ6JX.js.map +0 -1
  85. package/dist/chunk-Z5FV355Z.js.map +0 -1
@@ -4,6 +4,7 @@ import type {
4
4
  SessionHumanInputRequest,
5
5
  SubmitHumanInputResponseRequest,
6
6
  } from "@opengeni/sdk";
7
+ import { ChevronDownIcon, ChevronUpIcon, MessageCircleQuestionIcon } from "lucide-react";
7
8
  import { useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react";
8
9
  import { cn } from "../lib/cn";
9
10
 
@@ -23,28 +24,43 @@ export type HumanInputFormMessages = {
23
24
  deadlineLabel: string;
24
25
  formatDeadline: (value: string) => string;
25
26
  required: string;
26
- minLength: (count: number) => string;
27
- maxLength: (count: number) => string;
28
27
  otherRequired: string;
29
28
  minSelections: (count: number) => string;
30
29
  maxSelections: (count: number) => string;
30
+ optional: string;
31
+ /** Shown when the question list overflows the card and more content is below. */
32
+ moreBelow: string;
33
+ questionCount: (count: number) => string;
34
+ collapse: string;
35
+ expand: string;
36
+ selectionHint: (min: number | null | undefined, max: number | null | undefined) => string | null;
31
37
  };
32
38
 
33
39
  export const defaultHumanInputFormMessages: HumanInputFormMessages = {
34
- title: "Your input is needed",
35
- description: "The agent is paused until you answer these questions.",
40
+ title: "Input required",
41
+ /** Multi-question chrome has no default subtitle; hosts may still override. */
42
+ description: "",
36
43
  submit: "Continue",
37
44
  skip: "Skip",
38
45
  submitting: "Submitting…",
39
46
  other: "Other",
40
- deadlineLabel: "Respond before",
47
+ deadlineLabel: "Expires",
41
48
  formatDeadline,
42
49
  required: "This question is required.",
43
- minLength: (count) => `Enter at least ${count} characters.`,
44
- maxLength: (count) => `Enter no more than ${count} characters.`,
45
50
  otherRequired: "Enter a value for Other.",
46
51
  minSelections: (count) => `Choose at least ${count} option${count === 1 ? "" : "s"}.`,
47
52
  maxSelections: (count) => `Choose no more than ${count} option${count === 1 ? "" : "s"}.`,
53
+ optional: "Optional",
54
+ moreBelow: "More below",
55
+ questionCount: (count) => `${count} questions`,
56
+ collapse: "Collapse",
57
+ expand: "Expand",
58
+ selectionHint: (min, max) => {
59
+ if (min != null && max != null) return `Choose ${min}–${max}.`;
60
+ if (min != null) return `Choose at least ${min}.`;
61
+ if (max != null) return `Choose up to ${max}.`;
62
+ return null;
63
+ },
48
64
  };
49
65
 
50
66
  export type HumanInputFormProps = {
@@ -54,17 +70,22 @@ export type HumanInputFormProps = {
54
70
  error?: string | null | undefined;
55
71
  title?: ReactNode;
56
72
  description?: ReactNode;
73
+ /** e.g. "1 of 2" when a host is stepping through parallel requests. */
74
+ progressLabel?: ReactNode;
57
75
  submitLabel?: string | undefined;
58
76
  skipLabel?: string | undefined;
59
77
  messages?: Partial<HumanInputFormMessages> | undefined;
60
78
  autoFocus?: boolean | undefined;
79
+ /** Start collapsed to a compact bar (drafts still retained). */
80
+ defaultCollapsed?: boolean | undefined;
61
81
  className?: string | undefined;
62
82
  };
63
83
 
64
84
  /**
65
- * Styled but host-neutral renderer for one structured request. Hosts can use
66
- * the headless `useHumanInputRequests` hook instead, or replace the title and
67
- * description while retaining accessible field semantics and validation.
85
+ * Styled but host-neutral renderer for one structured request. Matches the
86
+ * waiting-tone decision language of ApprovalSurface: question-first, compact
87
+ * options, sticky ask/submit chrome. Hosts can replace title/description or
88
+ * use `useHumanInputRequests` headlessly.
68
89
  */
69
90
  export function HumanInputForm({
70
91
  request,
@@ -73,24 +94,43 @@ export function HumanInputForm({
73
94
  error,
74
95
  title,
75
96
  description,
97
+ progressLabel,
76
98
  submitLabel,
77
99
  skipLabel,
78
100
  messages: messageOverrides,
79
101
  autoFocus = true,
102
+ defaultCollapsed = false,
80
103
  className,
81
104
  }: HumanInputFormProps) {
82
105
  const messages = { ...defaultHumanInputFormMessages, ...messageOverrides };
83
- const resolvedTitle = title === undefined ? messages.title : title;
84
- const resolvedDescription = description === undefined ? messages.description : description;
106
+ const singleQuestion = request.questions.length === 1 ? request.questions[0]! : null;
107
+ const resolvedTitle =
108
+ title === undefined
109
+ ? singleQuestion
110
+ ? (singleQuestion.label ?? singleQuestion.prompt)
111
+ : messages.title
112
+ : title;
113
+ const resolvedDescription =
114
+ description === undefined
115
+ ? singleQuestion
116
+ ? singleQuestion.label
117
+ ? singleQuestion.prompt
118
+ : (singleQuestion.helpText ?? null)
119
+ : messages.description || null
120
+ : description;
85
121
  const resolvedSubmitLabel = submitLabel ?? messages.submit;
86
122
  const resolvedSkipLabel = skipLabel ?? messages.skip;
87
123
  const formId = useId();
124
+ const titleId = useId();
125
+ const scrollRef = useRef<HTMLDivElement>(null);
88
126
  const [drafts, setDrafts] = useState<Record<string, HumanInputAnswerDraft>>(() =>
89
127
  initialDrafts(request.questions),
90
128
  );
91
129
  const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
92
130
  const [submissionError, setSubmissionError] = useState<string | null>(null);
93
131
  const [submittingInternally, setSubmittingInternally] = useState(false);
132
+ const [overflowBelow, setOverflowBelow] = useState(false);
133
+ const [collapsed, setCollapsed] = useState(defaultCollapsed);
94
134
  const submissionInFlight = useRef(false);
95
135
  const submissionGeneration = useRef(0);
96
136
  const busy = submitting || submittingInternally;
@@ -102,7 +142,36 @@ export function HumanInputForm({
102
142
  setValidationErrors({});
103
143
  setSubmissionError(null);
104
144
  setSubmittingInternally(false);
105
- }, [request.id, request.questions]);
145
+ setCollapsed(defaultCollapsed);
146
+ }, [request.id, request.questions, defaultCollapsed]);
147
+
148
+ useEffect(() => {
149
+ if (collapsed) {
150
+ setOverflowBelow(false);
151
+ return;
152
+ }
153
+ const node = scrollRef.current;
154
+ if (!node) return;
155
+ const sync = () => {
156
+ const { scrollTop, scrollHeight, clientHeight } = node;
157
+ setOverflowBelow(
158
+ scrollHeight > clientHeight + 2 && scrollTop + clientHeight < scrollHeight - 4,
159
+ );
160
+ };
161
+ // Layout after paint: first sync can run before the max-height flex
162
+ // constraint resolves, which falsely reports no overflow.
163
+ const frame = requestAnimationFrame(sync);
164
+ node.addEventListener("scroll", sync, { passive: true });
165
+ const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(sync) : null;
166
+ observer?.observe(node);
167
+ const content = node.firstElementChild;
168
+ if (content) observer?.observe(content);
169
+ return () => {
170
+ cancelAnimationFrame(frame);
171
+ node.removeEventListener("scroll", sync);
172
+ observer?.disconnect();
173
+ };
174
+ }, [request.id, request.questions, collapsed]);
106
175
 
107
176
  const update = (
108
177
  questionId: string,
@@ -140,195 +209,233 @@ export function HumanInputForm({
140
209
  }
141
210
  };
142
211
 
212
+ const focusQuestion = (questionId: string): void => {
213
+ const root = scrollRef.current;
214
+ if (!root) return;
215
+ const block = Array.from(
216
+ root.querySelectorAll<HTMLElement>("[data-human-input-question]"),
217
+ ).find((node) => node.getAttribute("data-human-input-question") === questionId);
218
+ block?.scrollIntoView({ block: "nearest", behavior: "smooth" });
219
+ const focusable = block?.querySelector<HTMLElement>(
220
+ "input:not([type='hidden']):not([disabled]), textarea:not([disabled])",
221
+ );
222
+ focusable?.focus({ preventScroll: true });
223
+ };
224
+
143
225
  const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
144
226
  event.preventDefault();
145
227
  const result = answersFromDrafts(request.questions, drafts, messages);
146
228
  if (Object.keys(result.errors).length > 0) {
147
229
  setValidationErrors(result.errors);
230
+ const firstInvalid = request.questions.find((question) => question.id in result.errors);
231
+ if (firstInvalid) {
232
+ // After paint so aria-invalid / error text exist under the question.
233
+ requestAnimationFrame(() => focusQuestion(firstInvalid.id));
234
+ }
148
235
  return;
149
236
  }
150
237
  await submitResponse({ outcome: "answered", answers: result.answers });
151
238
  };
152
239
 
240
+ const metaBits = [
241
+ progressLabel,
242
+ !singleQuestion ? messages.questionCount(request.questions.length) : null,
243
+ request.expiresAt ? (
244
+ <>
245
+ {messages.deadlineLabel}{" "}
246
+ <time dateTime={request.expiresAt} title={new Date(request.expiresAt).toLocaleString()}>
247
+ {messages.formatDeadline(request.expiresAt)}
248
+ </time>
249
+ </>
250
+ ) : null,
251
+ ].filter(Boolean);
252
+
253
+ if (collapsed) {
254
+ return (
255
+ <div
256
+ data-human-input-request={request.id}
257
+ data-human-input-collapsed=""
258
+ aria-labelledby={titleId}
259
+ className={cn(
260
+ "og-root flex w-full items-center gap-3 rounded-og-lg border border-og-status-waiting/35 bg-og-status-waiting/5 px-3 py-2.5 shadow-og-sm",
261
+ className,
262
+ )}
263
+ >
264
+ <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-og-md bg-og-status-waiting/12 text-og-status-waiting">
265
+ <MessageCircleQuestionIcon aria-hidden="true" className="size-4" />
266
+ </span>
267
+ <div className="min-w-0 flex-1">
268
+ <h2 id={titleId} className="truncate text-og-sm font-semibold text-og-fg">
269
+ {resolvedTitle}
270
+ </h2>
271
+ {metaBits.length > 0 ? (
272
+ <p className="mt-0.5 truncate text-og-xs text-og-fg-subtle">{metaBits.join(" · ")}</p>
273
+ ) : null}
274
+ </div>
275
+ <button
276
+ type="button"
277
+ onClick={() => setCollapsed(false)}
278
+ className="inline-flex min-h-8 shrink-0 items-center gap-1 rounded-og-md border border-og-border px-2.5 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:bg-og-surface-1 hover:text-og-fg"
279
+ >
280
+ {messages.expand}
281
+ <ChevronDownIcon aria-hidden="true" className="size-3.5" />
282
+ </button>
283
+ </div>
284
+ );
285
+ }
286
+
153
287
  return (
154
288
  <form
155
289
  data-human-input-request={request.id}
156
290
  onSubmit={(event) => void submit(event)}
291
+ aria-labelledby={titleId}
157
292
  className={cn(
158
- "og-root flex w-full flex-col gap-5 rounded-og-lg border border-og-border bg-og-surface-1 p-5 shadow-og-sm",
293
+ // Multi-question: pin height at the cap so the flex body gets a real
294
+ // box and overflow-y engages. max-height alone + percentage/`h-full`
295
+ // children often sizes to content and clips with no scroll.
296
+ "og-root flex min-h-0 w-full flex-col overflow-hidden rounded-og-lg border border-og-status-waiting/35 bg-og-status-waiting/5 shadow-og-sm",
297
+ singleQuestion
298
+ ? "max-h-[min(28rem,50dvh)]"
299
+ : "h-[min(28rem,50dvh)] max-h-[min(28rem,50dvh)]",
159
300
  className,
160
301
  )}
161
302
  >
162
- <header>
163
- <h2 className="text-og-md font-semibold text-og-fg">{resolvedTitle}</h2>
164
- {resolvedDescription ? (
165
- <div className="mt-1 text-og-sm text-og-fg-muted">{resolvedDescription}</div>
166
- ) : null}
167
- {request.expiresAt ? (
168
- <p className="mt-1 text-og-xs text-og-fg-subtle">
169
- {messages.deadlineLabel}{" "}
170
- <time dateTime={request.expiresAt}>{messages.formatDeadline(request.expiresAt)}</time>
171
- </p>
172
- ) : null}
173
- </header>
174
-
175
- <fieldset disabled={busy} className="contents">
176
- {request.questions.map((question, index) => {
177
- const draft = drafts[question.id] ?? emptyDraft();
178
- const fieldId = `${formId}-${index}`;
179
- const errorId = `${fieldId}-error`;
180
- const helpId = `${fieldId}-help`;
181
- const describedBy =
182
- [question.helpText ? helpId : null, validationErrors[question.id] ? errorId : null]
183
- .filter(Boolean)
184
- .join(" ") || undefined;
185
- return (
186
- <div key={question.id} className="flex flex-col gap-2">
187
- <label
188
- htmlFor={question.kind === "text" ? fieldId : undefined}
189
- className="text-og-base font-medium text-og-fg"
190
- >
191
- {question.label ?? question.prompt}
192
- {question.required ? (
303
+ <header className="shrink-0 border-b border-og-status-waiting/20 px-4 py-3">
304
+ <div className="flex items-start gap-3">
305
+ <span className="mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-og-md bg-og-status-waiting/12 text-og-status-waiting">
306
+ <MessageCircleQuestionIcon aria-hidden="true" className="size-4" />
307
+ </span>
308
+ <div className="min-w-0 flex-1">
309
+ <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
310
+ <h2 id={titleId} className="text-og-md font-semibold text-og-fg">
311
+ {resolvedTitle}
312
+ {singleQuestion?.required && !request.allowSkip ? (
193
313
  <span aria-hidden className="ml-1 text-og-status-failed">
194
314
  *
195
315
  </span>
196
316
  ) : null}
197
- </label>
198
- {question.label ? (
199
- <p className="text-og-sm text-og-fg-muted">{question.prompt}</p>
317
+ </h2>
318
+ {progressLabel ? (
319
+ <span className="text-og-xs font-medium text-og-status-waiting">
320
+ {progressLabel}
321
+ </span>
200
322
  ) : null}
201
- {question.helpText ? (
202
- <p id={helpId} className="text-og-xs text-og-fg-subtle">
203
- {question.helpText}
204
- </p>
323
+ {!singleQuestion ? (
324
+ <span className="text-og-xs font-medium text-og-fg-subtle">
325
+ {messages.questionCount(request.questions.length)}
326
+ </span>
205
327
  ) : null}
206
- {question.kind === "text" ? (
207
- <textarea
208
- id={fieldId}
209
- value={draft.values[0] ?? ""}
210
- onChange={(event) =>
211
- update(question.id, (current) => ({
212
- ...current,
213
- values: event.target.value ? [event.target.value] : [],
214
- }))
215
- }
216
- aria-invalid={Boolean(validationErrors[question.id])}
217
- aria-describedby={describedBy}
218
- autoFocus={autoFocus && index === 0}
219
- rows={3}
220
- className="min-h-20 w-full resize-y rounded-og-md border border-og-border bg-og-surface-2 px-3 py-2 text-og-base text-og-fg outline-none placeholder:text-og-fg-subtle focus:border-og-accent"
221
- />
222
- ) : (
328
+ </div>
329
+ {resolvedDescription ? (
330
+ <div className="mt-0.5 text-og-sm text-og-fg-muted">{resolvedDescription}</div>
331
+ ) : null}
332
+ {request.expiresAt ? (
333
+ <p className="mt-1 text-og-xs text-og-fg-subtle">
334
+ {messages.deadlineLabel}{" "}
335
+ <time
336
+ dateTime={request.expiresAt}
337
+ title={new Date(request.expiresAt).toLocaleString()}
338
+ >
339
+ {messages.formatDeadline(request.expiresAt)}
340
+ </time>
341
+ </p>
342
+ ) : null}
343
+ {request.allowSkip && singleQuestion ? (
344
+ <p className="mt-1 text-og-xs text-og-fg-subtle">Or skip and let the agent decide.</p>
345
+ ) : null}
346
+ </div>
347
+ <button
348
+ type="button"
349
+ onClick={() => setCollapsed(true)}
350
+ aria-label={messages.collapse}
351
+ title={messages.collapse}
352
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-og-md text-og-fg-muted transition-colors hover:bg-og-surface-1 hover:text-og-fg"
353
+ >
354
+ <ChevronUpIcon aria-hidden="true" className="size-4" />
355
+ </button>
356
+ </div>
357
+ </header>
358
+
359
+ <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
360
+ <div
361
+ ref={scrollRef}
362
+ className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]"
363
+ >
364
+ <fieldset disabled={busy} className="min-w-0 space-y-4 px-4 py-3">
365
+ {request.questions.map((question, index) => {
366
+ if (singleQuestion) {
367
+ // Title already carries the question; only render the control + help extras.
368
+ return (
369
+ <div key={question.id} data-human-input-question={question.id}>
370
+ <QuestionControls
371
+ question={question}
372
+ draft={drafts[question.id] ?? emptyDraft()}
373
+ fieldId={`${formId}-${index}`}
374
+ error={validationErrors[question.id]}
375
+ messages={messages}
376
+ autoFocus={autoFocus}
377
+ firstOption
378
+ showPromptChrome={false}
379
+ allowSkip={request.allowSkip}
380
+ busy={busy}
381
+ onUpdate={(apply) => update(question.id, apply)}
382
+ />
383
+ </div>
384
+ );
385
+ }
386
+ return (
223
387
  <div
224
- role={question.kind === "single_select" ? "radiogroup" : "group"}
225
- aria-describedby={describedBy}
226
- className="flex flex-col gap-2"
388
+ key={question.id}
389
+ data-human-input-question={question.id}
390
+ className="flex flex-col gap-1.5"
227
391
  >
228
- {question.options.map((option, optionIndex) => {
229
- const checked = draft.values.includes(option.id);
230
- return (
231
- <label
232
- key={option.id}
233
- className="flex cursor-pointer items-start gap-2 rounded-og-md border border-og-border px-3 py-2 hover:bg-og-surface-2"
234
- >
235
- <input
236
- type={question.kind === "single_select" ? "radio" : "checkbox"}
237
- name={question.kind === "single_select" ? fieldId : undefined}
238
- autoFocus={autoFocus && index === 0 && optionIndex === 0}
239
- checked={checked}
240
- onChange={(event) =>
241
- update(question.id, (current) => ({
242
- ...current,
243
- values:
244
- question.kind === "single_select"
245
- ? event.target.checked
246
- ? [option.id]
247
- : []
248
- : event.target.checked
249
- ? [...current.values, option.id]
250
- : current.values.filter((value) => value !== option.id),
251
- ...(question.kind === "single_select" && event.target.checked
252
- ? { otherSelected: false }
253
- : {}),
254
- }))
255
- }
256
- className="mt-0.5 accent-og-accent"
257
- />
258
- <span className="min-w-0">
259
- <span className="block text-og-sm font-medium text-og-fg">
260
- {option.label}
261
- </span>
262
- {option.description ? (
263
- <span className="block text-og-xs text-og-fg-muted">
264
- {option.description}
265
- </span>
266
- ) : null}
267
- </span>
268
- </label>
269
- );
270
- })}
271
- {question.allowOther ? (
272
- <label className="flex items-start gap-2 rounded-og-md border border-og-border px-3 py-2">
273
- <input
274
- type={question.kind === "single_select" ? "radio" : "checkbox"}
275
- name={question.kind === "single_select" ? fieldId : undefined}
276
- checked={draft.otherSelected}
277
- autoFocus={autoFocus && index === 0 && question.options.length === 0}
278
- onChange={(event) =>
279
- update(question.id, (current) => ({
280
- ...current,
281
- otherSelected: event.target.checked,
282
- ...(question.kind === "single_select" && event.target.checked
283
- ? { values: [] }
284
- : {}),
285
- }))
286
- }
287
- className="mt-2 accent-og-accent"
288
- />
289
- <span className="min-w-0 flex-1">
290
- <span className="block text-og-sm font-medium text-og-fg">
291
- {messages.other}
292
- </span>
293
- <input
294
- type="text"
295
- value={draft.other}
296
- disabled={!draft.otherSelected || busy}
297
- onChange={(event) =>
298
- update(question.id, (current) => ({
299
- ...current,
300
- other: event.target.value,
301
- }))
302
- }
303
- className="mt-1 w-full rounded-og-sm border border-og-border bg-og-surface-2 px-2 py-1.5 text-og-sm text-og-fg outline-none focus:border-og-accent disabled:opacity-50"
304
- />
305
- </span>
306
- </label>
307
- ) : null}
392
+ <QuestionControls
393
+ question={question}
394
+ draft={drafts[question.id] ?? emptyDraft()}
395
+ fieldId={`${formId}-${index}`}
396
+ error={validationErrors[question.id]}
397
+ messages={messages}
398
+ autoFocus={autoFocus && index === 0}
399
+ firstOption={index === 0}
400
+ showPromptChrome
401
+ allowSkip={request.allowSkip}
402
+ busy={busy}
403
+ onUpdate={(apply) => update(question.id, apply)}
404
+ />
308
405
  </div>
309
- )}
310
- {validationErrors[question.id] ? (
311
- <p id={errorId} role="alert" className="text-og-xs text-og-status-failed">
312
- {validationErrors[question.id]}
313
- </p>
314
- ) : null}
315
- </div>
316
- );
317
- })}
318
- </fieldset>
406
+ );
407
+ })}
408
+ </fieldset>
409
+ </div>
410
+ {overflowBelow ? (
411
+ <div
412
+ className="pointer-events-none absolute inset-x-0 bottom-0 z-[1] flex flex-col items-center"
413
+ aria-hidden="true"
414
+ >
415
+ <div className="h-10 w-full bg-gradient-to-t from-og-surface-1 via-og-surface-1/85 to-transparent" />
416
+ <span className="-mt-5 mb-1 rounded-og-full bg-og-surface-1 px-2.5 py-0.5 text-og-xs font-medium text-og-fg-muted shadow-og-sm ring-1 ring-og-border/60">
417
+ {messages.moreBelow}
418
+ </span>
419
+ </div>
420
+ ) : null}
421
+ </div>
319
422
 
320
423
  {(error ?? submissionError) ? (
321
- <p role="alert" className="text-og-sm text-og-status-failed">
424
+ <p
425
+ role="alert"
426
+ className="relative z-10 shrink-0 px-4 pb-1 text-og-sm text-og-status-failed"
427
+ >
322
428
  {error ?? submissionError}
323
429
  </p>
324
430
  ) : null}
325
- <footer className="flex items-center justify-end gap-2">
431
+
432
+ <footer className="relative z-10 flex shrink-0 items-center justify-end gap-2 border-t border-og-status-waiting/20 bg-og-surface-1/95 px-4 py-3 backdrop-blur-[2px]">
326
433
  {request.allowSkip ? (
327
434
  <button
328
435
  type="button"
329
436
  disabled={busy}
330
437
  onClick={() => void submitResponse({ outcome: "skipped" })}
331
- className="rounded-og-md border border-og-border px-3 py-2 text-og-sm font-medium text-og-fg-muted hover:bg-og-surface-2 disabled:opacity-50"
438
+ className="inline-flex min-h-9 items-center rounded-og-md border border-og-border px-3 py-1.5 text-og-sm font-medium text-og-fg-muted transition-colors hover:bg-og-surface-1 hover:text-og-fg disabled:opacity-50"
332
439
  >
333
440
  {resolvedSkipLabel}
334
441
  </button>
@@ -336,7 +443,7 @@ export function HumanInputForm({
336
443
  <button
337
444
  type="submit"
338
445
  disabled={busy}
339
- className="rounded-og-md bg-og-accent px-3 py-2 text-og-sm font-medium text-og-accent-fg hover:bg-og-accent-strong disabled:opacity-50"
446
+ className="inline-flex min-h-9 items-center rounded-og-md bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg transition-colors hover:bg-og-accent-strong disabled:opacity-50"
340
447
  >
341
448
  {busy ? messages.submitting : resolvedSubmitLabel}
342
449
  </button>
@@ -345,6 +452,221 @@ export function HumanInputForm({
345
452
  );
346
453
  }
347
454
 
455
+ function QuestionControls({
456
+ question,
457
+ draft,
458
+ fieldId,
459
+ error,
460
+ messages,
461
+ autoFocus,
462
+ firstOption,
463
+ showPromptChrome,
464
+ allowSkip,
465
+ busy,
466
+ onUpdate,
467
+ }: {
468
+ question: HumanInputQuestion;
469
+ draft: HumanInputAnswerDraft;
470
+ fieldId: string;
471
+ error: string | undefined;
472
+ messages: HumanInputFormMessages;
473
+ autoFocus: boolean;
474
+ firstOption: boolean;
475
+ showPromptChrome: boolean;
476
+ allowSkip: boolean;
477
+ busy: boolean;
478
+ onUpdate: (apply: (draft: HumanInputAnswerDraft) => HumanInputAnswerDraft) => void;
479
+ }) {
480
+ const errorId = `${fieldId}-error`;
481
+ const helpId = `${fieldId}-help`;
482
+ const hint =
483
+ question.kind === "multi_select"
484
+ ? messages.selectionHint(
485
+ question.validation?.minSelections,
486
+ question.validation?.maxSelections,
487
+ )
488
+ : null;
489
+ const describedBy =
490
+ [
491
+ question.helpText && showPromptChrome ? helpId : null,
492
+ hint ? `${fieldId}-hint` : null,
493
+ error ? errorId : null,
494
+ ]
495
+ .filter(Boolean)
496
+ .join(" ") || undefined;
497
+ return (
498
+ <>
499
+ {showPromptChrome ? (
500
+ <>
501
+ <div className="flex flex-wrap items-baseline gap-x-2">
502
+ <label
503
+ htmlFor={question.kind === "text" ? fieldId : undefined}
504
+ className="text-og-sm font-medium text-og-fg"
505
+ >
506
+ {question.label ?? question.prompt}
507
+ {question.required && !allowSkip ? (
508
+ <span aria-hidden className="ml-1 text-og-status-failed">
509
+ *
510
+ </span>
511
+ ) : !question.required ? (
512
+ <span className="ml-1.5 text-og-xs font-normal text-og-fg-subtle">
513
+ {messages.optional}
514
+ </span>
515
+ ) : null}
516
+ </label>
517
+ </div>
518
+ {question.label ? <p className="text-og-sm text-og-fg-muted">{question.prompt}</p> : null}
519
+ {question.helpText ? (
520
+ <p id={helpId} className="text-og-xs text-og-fg-subtle">
521
+ {question.helpText}
522
+ </p>
523
+ ) : null}
524
+ {hint ? (
525
+ <p id={`${fieldId}-hint`} className="text-og-xs text-og-fg-subtle">
526
+ {hint}
527
+ </p>
528
+ ) : null}
529
+ </>
530
+ ) : (
531
+ <>
532
+ {!question.required && allowSkip === false ? (
533
+ <p className="text-og-xs text-og-fg-subtle">{messages.optional}</p>
534
+ ) : null}
535
+ {question.helpText && question.label ? (
536
+ <p id={helpId} className="mb-1.5 text-og-xs text-og-fg-subtle">
537
+ {question.helpText}
538
+ </p>
539
+ ) : null}
540
+ {hint ? (
541
+ <p id={`${fieldId}-hint`} className="mb-1.5 text-og-xs text-og-fg-subtle">
542
+ {hint}
543
+ </p>
544
+ ) : null}
545
+ </>
546
+ )}
547
+
548
+ {question.kind === "text" ? (
549
+ <textarea
550
+ id={fieldId}
551
+ value={draft.values[0] ?? ""}
552
+ onChange={(event) =>
553
+ onUpdate((current) => ({
554
+ ...current,
555
+ values: event.target.value ? [event.target.value] : [],
556
+ }))
557
+ }
558
+ aria-invalid={Boolean(error)}
559
+ aria-describedby={describedBy}
560
+ autoFocus={autoFocus}
561
+ rows={2}
562
+ className="min-h-14 w-full resize-y rounded-og-md border border-og-border bg-og-surface-1 px-3 py-2 text-og-sm text-og-fg outline-none placeholder:text-og-fg-subtle focus:border-og-accent"
563
+ />
564
+ ) : (
565
+ <div
566
+ role={question.kind === "single_select" ? "radiogroup" : "group"}
567
+ aria-describedby={describedBy}
568
+ className="flex flex-col gap-0.5"
569
+ >
570
+ {question.options.map((option, optionIndex) => {
571
+ const checked = draft.values.includes(option.id);
572
+ return (
573
+ <label
574
+ key={option.id}
575
+ className={cn(
576
+ "flex cursor-pointer items-start gap-2.5 rounded-og-md px-2.5 py-2 transition-colors",
577
+ checked
578
+ ? "bg-og-status-waiting/12 text-og-fg"
579
+ : "text-og-fg hover:bg-og-surface-1/80",
580
+ )}
581
+ >
582
+ <input
583
+ type={question.kind === "single_select" ? "radio" : "checkbox"}
584
+ name={question.kind === "single_select" ? fieldId : undefined}
585
+ autoFocus={autoFocus && firstOption && optionIndex === 0}
586
+ checked={checked}
587
+ onChange={(event) =>
588
+ onUpdate((current) => ({
589
+ ...current,
590
+ values:
591
+ question.kind === "single_select"
592
+ ? event.target.checked
593
+ ? [option.id]
594
+ : []
595
+ : event.target.checked
596
+ ? [...current.values, option.id]
597
+ : current.values.filter((value) => value !== option.id),
598
+ ...(question.kind === "single_select" && event.target.checked
599
+ ? { otherSelected: false }
600
+ : {}),
601
+ }))
602
+ }
603
+ className="mt-0.5 accent-og-accent"
604
+ />
605
+ <span className="min-w-0">
606
+ <span className="block text-og-sm font-medium">{option.label}</span>
607
+ {option.description ? (
608
+ <span className="mt-0.5 block text-og-xs text-og-fg-muted">
609
+ {option.description}
610
+ </span>
611
+ ) : null}
612
+ </span>
613
+ </label>
614
+ );
615
+ })}
616
+ {question.allowOther ? (
617
+ <label
618
+ className={cn(
619
+ "flex items-start gap-2.5 rounded-og-md px-2.5 py-2 transition-colors",
620
+ draft.otherSelected
621
+ ? "bg-og-status-waiting/12 text-og-fg"
622
+ : "text-og-fg hover:bg-og-surface-1/80",
623
+ )}
624
+ >
625
+ <input
626
+ type={question.kind === "single_select" ? "radio" : "checkbox"}
627
+ name={question.kind === "single_select" ? fieldId : undefined}
628
+ checked={draft.otherSelected}
629
+ autoFocus={autoFocus && firstOption && question.options.length === 0}
630
+ onChange={(event) =>
631
+ onUpdate((current) => ({
632
+ ...current,
633
+ otherSelected: event.target.checked,
634
+ ...(question.kind === "single_select" && event.target.checked
635
+ ? { values: [] }
636
+ : {}),
637
+ }))
638
+ }
639
+ className="mt-2 accent-og-accent"
640
+ />
641
+ <span className="min-w-0 flex-1">
642
+ <span className="block text-og-sm font-medium">{messages.other}</span>
643
+ <input
644
+ type="text"
645
+ value={draft.other}
646
+ disabled={!draft.otherSelected || busy}
647
+ placeholder="Type a value…"
648
+ onChange={(event) =>
649
+ onUpdate((current) => ({
650
+ ...current,
651
+ other: event.target.value,
652
+ }))
653
+ }
654
+ className="mt-1.5 w-full rounded-og-sm border border-og-border bg-og-surface-1 px-2 py-1.5 text-og-sm text-og-fg outline-none focus:border-og-accent disabled:opacity-50"
655
+ />
656
+ </span>
657
+ </label>
658
+ ) : null}
659
+ </div>
660
+ )}
661
+ {error ? (
662
+ <p id={errorId} role="alert" className="text-og-xs text-og-status-failed">
663
+ {error}
664
+ </p>
665
+ ) : null}
666
+ </>
667
+ );
668
+ }
669
+
348
670
  export function answersFromDrafts(
349
671
  questions: HumanInputQuestion[],
350
672
  drafts: Record<string, HumanInputAnswerDraft>,
@@ -358,29 +680,19 @@ export function answersFromDrafts(
358
680
  const values = question.kind === "text" ? draft.values.filter(Boolean) : draft.values;
359
681
  const other = draft.otherSelected ? draft.other.trim() : "";
360
682
  const supplied = values.length + (other ? 1 : 0);
683
+
684
+ // Other-selected-but-empty must win over generic "required" — otherwise the
685
+ // user sees the wrong diagnosis next to a clearly selected control.
686
+ if (question.kind !== "text" && draft.otherSelected && !other) {
687
+ errors[question.id] = messages.otherRequired;
688
+ continue;
689
+ }
690
+
361
691
  if (question.required && supplied === 0) {
362
692
  errors[question.id] = messages.required;
363
693
  continue;
364
694
  }
365
- if (question.kind === "text") {
366
- const value = values[0] ?? "";
367
- if (
368
- value &&
369
- question.validation?.minLength != null &&
370
- value.length < question.validation.minLength
371
- ) {
372
- errors[question.id] = messages.minLength(question.validation.minLength);
373
- continue;
374
- }
375
- if (question.validation?.maxLength != null && value.length > question.validation.maxLength) {
376
- errors[question.id] = messages.maxLength(question.validation.maxLength);
377
- continue;
378
- }
379
- } else {
380
- if (draft.otherSelected && !other) {
381
- errors[question.id] = messages.otherRequired;
382
- continue;
383
- }
695
+ if (question.kind !== "text") {
384
696
  const min = question.validation?.minSelections;
385
697
  const max = question.kind === "single_select" ? 1 : question.validation?.maxSelections;
386
698
  if (min != null && supplied < min) {
@@ -413,5 +725,13 @@ function emptyDraft(): HumanInputAnswerDraft {
413
725
 
414
726
  function formatDeadline(value: string): string {
415
727
  const date = new Date(value);
416
- return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
728
+ if (Number.isNaN(date.getTime())) return value;
729
+ const ms = date.getTime() - Date.now();
730
+ if (ms <= 0) return "deadline passed";
731
+ const minutes = Math.round(ms / 60_000);
732
+ if (minutes < 1) return "in under a minute";
733
+ if (minutes < 60) return `in ${minutes}m`;
734
+ const hours = Math.round(minutes / 60);
735
+ if (hours < 48) return `in ${hours}h`;
736
+ return date.toLocaleString();
417
737
  }