@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.
- package/README.md +92 -2
- package/dist/{chunk-J2RVJ6JX.js → chunk-4IJCL7YO.js} +13 -2
- package/dist/chunk-4IJCL7YO.js.map +1 -0
- package/dist/{chunk-7CJOAW3V.js → chunk-EB67J347.js} +1 -135
- package/dist/chunk-EB67J347.js.map +1 -0
- package/dist/chunk-EKZH6IDG.js +141 -0
- package/dist/chunk-EKZH6IDG.js.map +1 -0
- package/dist/{chunk-OKKMZDQF.js → chunk-JALF5FI3.js} +9 -7
- package/dist/{chunk-OKKMZDQF.js.map → chunk-JALF5FI3.js.map} +1 -1
- package/dist/{chunk-Z5FV355Z.js → chunk-KR2SK5GJ.js} +1382 -724
- package/dist/chunk-KR2SK5GJ.js.map +1 -0
- package/dist/{chunk-N4XEBE23.js → chunk-OMCFRWHL.js} +22 -13
- package/dist/{chunk-N4XEBE23.js.map → chunk-OMCFRWHL.js.map} +1 -1
- package/dist/{chunk-FT2TXNGZ.js → chunk-Q2NCKWTK.js} +183 -18
- package/dist/chunk-Q2NCKWTK.js.map +1 -0
- package/dist/{chunk-22RM4GMO.js → chunk-SJKT4TKW.js} +26 -2
- package/dist/chunk-SJKT4TKW.js.map +1 -0
- package/dist/{chunk-ALA3UGWB.js → chunk-SXIQK54Z.js} +8 -6
- package/dist/{chunk-ALA3UGWB.js.map → chunk-SXIQK54Z.js.map} +1 -1
- package/dist/chunk-WZT5G5OR.js +433 -0
- package/dist/chunk-WZT5G5OR.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/components/chat-composer.d.ts +7 -1
- package/dist/components/human-input-form.d.ts +16 -6
- package/dist/components/human-input-surface.d.ts +19 -0
- package/dist/components/machine-input-display.d.ts +12 -0
- package/dist/components/message-timeline.d.ts +6 -1
- package/dist/components/model-policy-picker.d.ts +78 -0
- package/dist/components/session-chrome.d.ts +2 -1
- package/dist/components/tip-follow.d.ts +30 -0
- package/dist/composer.d.ts +2 -0
- package/dist/composer.js +22 -5
- package/dist/hooks/use-composer.d.ts +10 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +2261 -2230
- package/dist/index.js.map +1 -1
- package/dist/machines.js +4 -3
- package/dist/model-policy.d.ts +12 -4
- package/dist/model-policy.js +5 -1
- package/dist/realtime/dropdown-menu.d.ts +16 -0
- package/dist/realtime/realtime-control.d.ts +126 -0
- package/dist/realtime.d.ts +11 -0
- package/dist/realtime.js +1023 -0
- package/dist/realtime.js.map +1 -0
- package/dist/session-context.d.ts +7 -1
- package/dist/session-ui.d.ts +2 -0
- package/dist/session-ui.js +6 -4
- package/dist/session.js +5 -4
- package/dist/timeline/compute-label.d.ts +2 -0
- package/dist/timeline/index.d.ts +1 -0
- package/dist/timeline/types.d.ts +5 -0
- package/package.json +7 -2
- package/src/client.ts +13 -0
- package/src/components/chat-composer.tsx +19 -5
- package/src/components/composer-transcription-control.tsx +2 -2
- package/src/components/composer.tsx +14 -5
- package/src/components/human-input-form.tsx +506 -186
- package/src/components/human-input-surface.tsx +80 -0
- package/src/components/machine-input-display.ts +83 -0
- package/src/components/message-timeline.tsx +348 -308
- package/src/components/model-policy-picker.tsx +587 -0
- package/src/components/sandbox-files.tsx +1 -1
- package/src/components/session-chrome.tsx +141 -15
- package/src/components/tip-follow.ts +127 -6
- package/src/composer.ts +13 -0
- package/src/hooks/use-composer.ts +229 -29
- package/src/index.ts +19 -0
- package/src/model-policy.ts +51 -7
- package/src/realtime/dropdown-menu.tsx +123 -0
- package/src/realtime/realtime-control.tsx +1178 -0
- package/src/realtime.ts +27 -0
- package/src/session-context.ts +16 -0
- package/src/session-ui.ts +2 -0
- package/src/timeline/compute-label.tsx +18 -0
- package/src/timeline/index.ts +3 -0
- package/src/timeline/projection.ts +21 -1
- package/src/timeline/tool-renderers.tsx +232 -43
- package/src/timeline/types.ts +5 -0
- package/styles/index.css +61 -0
- package/styles/tokens.css +2 -0
- package/dist/chunk-22RM4GMO.js.map +0 -1
- package/dist/chunk-7CJOAW3V.js.map +0 -1
- package/dist/chunk-FT2TXNGZ.js.map +0 -1
- package/dist/chunk-J2RVJ6JX.js.map +0 -1
- 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: "
|
|
35
|
-
|
|
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: "
|
|
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.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
|
84
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
</
|
|
198
|
-
{
|
|
199
|
-
<
|
|
317
|
+
</h2>
|
|
318
|
+
{progressLabel ? (
|
|
319
|
+
<span className="text-og-xs font-medium text-og-status-waiting">
|
|
320
|
+
{progressLabel}
|
|
321
|
+
</span>
|
|
200
322
|
) : null}
|
|
201
|
-
{
|
|
202
|
-
<
|
|
203
|
-
{
|
|
204
|
-
</
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
className="flex flex-col gap-
|
|
388
|
+
key={question.id}
|
|
389
|
+
data-human-input-question={question.id}
|
|
390
|
+
className="flex flex-col gap-1.5"
|
|
227
391
|
>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
}
|