@hachej/boring-ask-user 0.1.13
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 +266 -0
- package/dist/front/index.d.ts +17 -0
- package/dist/front/index.js +546 -0
- package/dist/server/index.d.ts +205 -0
- package/dist/server/index.js +1011 -0
- package/dist/shared/index.d.ts +3443 -0
- package/dist/shared/index.js +305 -0
- package/dist/types-CF72YmK-.d.ts +193 -0
- package/package.json +72 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/front/index.tsx
|
|
4
|
+
import { Button, EmptyState, Notice, Pane, PaneBody, PaneFooter, PaneHeader, PaneTitle } from "@hachej/boring-ui-kit";
|
|
5
|
+
import {
|
|
6
|
+
UI_COMMAND_EVENT,
|
|
7
|
+
useWorkspaceAttention
|
|
8
|
+
} from "@hachej/boring-workspace";
|
|
9
|
+
import {
|
|
10
|
+
definePlugin
|
|
11
|
+
} from "@hachej/boring-workspace/plugin";
|
|
12
|
+
import { HelpCircle, XCircle } from "lucide-react";
|
|
13
|
+
import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useMemo as useMemo2, useSyncExternalStore, useState as useState2 } from "react";
|
|
14
|
+
|
|
15
|
+
// src/shared/constants.ts
|
|
16
|
+
var ASK_USER_PLUGIN_ID = "ask-user";
|
|
17
|
+
var ASK_USER_PANEL_ID = "ask-user.questions";
|
|
18
|
+
var ASK_USER_PANEL_TITLE = "Questions";
|
|
19
|
+
var ASK_USER_SURFACE_KIND = "questions";
|
|
20
|
+
var ASK_USER_COMMAND_KINDS = {
|
|
21
|
+
SUBMIT: "questions.submit",
|
|
22
|
+
CANCEL: "questions.cancel"
|
|
23
|
+
};
|
|
24
|
+
var ASK_USER_UI_STATE_SLOTS = {
|
|
25
|
+
PENDING: "questions.pending"
|
|
26
|
+
};
|
|
27
|
+
var ASK_USER_SCHEMA_LIMITS = {
|
|
28
|
+
maxFields: 8,
|
|
29
|
+
maxOptionsPerField: 50,
|
|
30
|
+
maxFieldNameLength: 64,
|
|
31
|
+
maxTitleLength: 200,
|
|
32
|
+
maxLabelLength: 160,
|
|
33
|
+
maxHelpTextLength: 500,
|
|
34
|
+
maxContextLength: 4e3,
|
|
35
|
+
maxSerializedSchemaBytes: 32e3,
|
|
36
|
+
maxFreeformAnswerLength: 4e3,
|
|
37
|
+
minTimeoutMs: 1e3,
|
|
38
|
+
maxTimeoutMs: 30 * 6e4,
|
|
39
|
+
defaultTimeoutMs: 10 * 6e4
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// src/shared/error-codes.ts
|
|
43
|
+
var ASK_USER_ERROR_CODES = {
|
|
44
|
+
PENDING_EXISTS: "ASK_USER_PENDING_EXISTS",
|
|
45
|
+
QUESTION_NOT_FOUND: "ASK_USER_QUESTION_NOT_FOUND",
|
|
46
|
+
QUESTION_ABANDONED: "ASK_USER_QUESTION_ABANDONED",
|
|
47
|
+
QUESTION_NOT_READY: "ASK_USER_QUESTION_NOT_READY",
|
|
48
|
+
SCHEMA_INVALID: "ASK_USER_SCHEMA_INVALID",
|
|
49
|
+
ANSWER_INVALID: "ASK_USER_ANSWER_INVALID",
|
|
50
|
+
SESSION_MISMATCH: "ASK_USER_SESSION_MISMATCH",
|
|
51
|
+
UI_UNAVAILABLE: "ASK_USER_UI_UNAVAILABLE",
|
|
52
|
+
ALREADY_ANSWERED: "ASK_USER_ALREADY_ANSWERED",
|
|
53
|
+
ALREADY_CANCELLED: "ASK_USER_ALREADY_CANCELLED",
|
|
54
|
+
UNAUTHORIZED: "ASK_USER_UNAUTHORIZED",
|
|
55
|
+
UI_ACK_TIMEOUT: "ASK_USER_UI_ACK_TIMEOUT",
|
|
56
|
+
RATE_LIMITED: "ASK_USER_RATE_LIMITED",
|
|
57
|
+
RUNTIME_UNAVAILABLE: "ASK_USER_RUNTIME_UNAVAILABLE"
|
|
58
|
+
};
|
|
59
|
+
var ASK_USER_ERROR_CODE_VALUES = Object.values(ASK_USER_ERROR_CODES);
|
|
60
|
+
|
|
61
|
+
// src/front/primitives/QuestionForm.tsx
|
|
62
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
63
|
+
import { Checkbox, ChoiceGroup, ChoiceGroupLegend, ChoiceItem, ChoiceItemBody, ChoiceItemDescription, ChoiceItemTitle, Field, FieldDescription, FieldError, FieldLabel, Input, Radio, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from "@hachej/boring-ui-kit";
|
|
64
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
65
|
+
var QuestionFormContext = createContext(null);
|
|
66
|
+
var fieldClass = "space-y-1.5";
|
|
67
|
+
var choiceControlClass = "mt-0.5";
|
|
68
|
+
function useQuestionForm() {
|
|
69
|
+
const ctx = useContext(QuestionFormContext);
|
|
70
|
+
if (!ctx) throw new Error("useQuestionForm must be used inside QuestionFormProvider");
|
|
71
|
+
return ctx;
|
|
72
|
+
}
|
|
73
|
+
function QuestionFormProvider({
|
|
74
|
+
schema,
|
|
75
|
+
status = "ready",
|
|
76
|
+
disabled = false,
|
|
77
|
+
submitting: controlledSubmitting,
|
|
78
|
+
initialValues,
|
|
79
|
+
rendererRegistry = {},
|
|
80
|
+
onSubmit,
|
|
81
|
+
onCancel,
|
|
82
|
+
children
|
|
83
|
+
}) {
|
|
84
|
+
const [values, setValues] = useState(() => defaultsFor(schema, initialValues));
|
|
85
|
+
const [touched, setTouched] = useState({});
|
|
86
|
+
const [submitting, setSubmitting] = useState(false);
|
|
87
|
+
const [submitted, setSubmitted] = useState(false);
|
|
88
|
+
const dirtyHints = {};
|
|
89
|
+
const formRef = useRef(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
setValues((current) => {
|
|
92
|
+
const next = defaultsFor(schema, initialValues);
|
|
93
|
+
for (const field of schema?.fields ?? []) {
|
|
94
|
+
if (touched[field.name] && current[field.name] !== void 0) {
|
|
95
|
+
next[field.name] = current[field.name];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return next;
|
|
99
|
+
});
|
|
100
|
+
}, [schema, initialValues, touched]);
|
|
101
|
+
const errors = useMemo(() => {
|
|
102
|
+
if (!schema) return {};
|
|
103
|
+
const allErrors = validateQuestionValues(schema, values).errors;
|
|
104
|
+
if (submitted) return allErrors;
|
|
105
|
+
return Object.fromEntries(Object.entries(allErrors).filter(([name]) => touched[name]));
|
|
106
|
+
}, [schema, values, submitted, touched]);
|
|
107
|
+
const setValue = useCallback((name, value2) => {
|
|
108
|
+
setTouched((current) => ({ ...current, [name]: true }));
|
|
109
|
+
setValues((current) => ({ ...current, [name]: value2 }));
|
|
110
|
+
}, []);
|
|
111
|
+
const touch = useCallback((name) => setTouched((current) => ({ ...current, [name]: true })), []);
|
|
112
|
+
const submit = useCallback(async () => {
|
|
113
|
+
if (!schema || status !== "ready" || disabled) return;
|
|
114
|
+
const result = validateQuestionValues(schema, values);
|
|
115
|
+
if (!result.valid) {
|
|
116
|
+
setSubmitted(true);
|
|
117
|
+
const first = Object.keys(result.errors)[0];
|
|
118
|
+
formRef.current?.querySelector(`[name="${cssEscape(first)}"]`)?.focus();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setSubmitting(true);
|
|
122
|
+
try {
|
|
123
|
+
await onSubmit?.(values);
|
|
124
|
+
} finally {
|
|
125
|
+
setSubmitting(false);
|
|
126
|
+
}
|
|
127
|
+
}, [schema, status, disabled, values, onSubmit]);
|
|
128
|
+
const cancel = useCallback(() => {
|
|
129
|
+
if (Object.keys(touched).length > 0 && !window.confirm("Discard your answer?")) return;
|
|
130
|
+
onCancel?.();
|
|
131
|
+
}, [onCancel, touched]);
|
|
132
|
+
const value = { schema, status, values, touched, errors, submitting: controlledSubmitting ?? submitting, disabled, dirtyHints, setValue, touch, submit, cancel, rendererRegistry, formRef };
|
|
133
|
+
return /* @__PURE__ */ jsx(QuestionFormContext.Provider, { value, children });
|
|
134
|
+
}
|
|
135
|
+
function QuestionForm({ children, "aria-label": ariaLabel = "Question form" }) {
|
|
136
|
+
const { submit, cancel, status, formRef } = useQuestionForm();
|
|
137
|
+
return /* @__PURE__ */ jsxs("form", { ref: formRef, "data-question-form": true, "aria-label": ariaLabel, onSubmit: (event) => {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
void submit();
|
|
140
|
+
}, onKeyDown: (event) => {
|
|
141
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
void submit();
|
|
144
|
+
}
|
|
145
|
+
if (event.key === "Escape") {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
cancel();
|
|
148
|
+
}
|
|
149
|
+
}, children: [
|
|
150
|
+
/* @__PURE__ */ jsx("div", { "aria-live": "polite", className: "sr-only", children: status === "ready" ? "Question ready" : `Question ${status}` }),
|
|
151
|
+
children
|
|
152
|
+
] });
|
|
153
|
+
}
|
|
154
|
+
function QuestionFields() {
|
|
155
|
+
const { schema } = useQuestionForm();
|
|
156
|
+
if (!schema) return null;
|
|
157
|
+
return /* @__PURE__ */ jsx(Fragment, { children: schema.fields.map((field) => /* @__PURE__ */ jsx(QuestionField, { field }, field.name)) });
|
|
158
|
+
}
|
|
159
|
+
function QuestionField({ field }) {
|
|
160
|
+
const { values, errors, disabled, submitting, setValue, touch, rendererRegistry, dirtyHints } = useQuestionForm();
|
|
161
|
+
const error = errors[field.name];
|
|
162
|
+
const helpId = `${field.name}-help`;
|
|
163
|
+
const errorId = `${field.name}-error`;
|
|
164
|
+
const hintId = `${field.name}-hint`;
|
|
165
|
+
const describedBy = [field.helpText ? helpId : void 0, error ? errorId : void 0, dirtyHints[field.name] ? hintId : void 0].filter(Boolean).join(" ");
|
|
166
|
+
const renderer = rendererRegistry[field.type] ?? defaultRenderers[field.type] ?? rendererRegistry.unsupported ?? UnsupportedFieldRenderer;
|
|
167
|
+
return /* @__PURE__ */ jsxs(Field, { "data-field": field.name, className: fieldClass, children: [
|
|
168
|
+
renderer({ field, value: values[field.name], error, disabled: disabled || submitting, describedBy, onChange: (value) => setValue(field.name, value), onBlur: () => touch(field.name) }),
|
|
169
|
+
field.helpText ? /* @__PURE__ */ jsx(FieldDescription, { id: helpId, children: field.helpText }) : null,
|
|
170
|
+
dirtyHints[field.name] ? /* @__PURE__ */ jsx(FieldDescription, { id: hintId, children: dirtyHints[field.name] }) : null,
|
|
171
|
+
error ? /* @__PURE__ */ jsx(FieldError, { id: errorId, role: "alert", children: error }) : null
|
|
172
|
+
] });
|
|
173
|
+
}
|
|
174
|
+
function QuestionSubmitButton(props) {
|
|
175
|
+
const { status, submitting, disabled } = useQuestionForm();
|
|
176
|
+
return /* @__PURE__ */ jsx("button", { ...props, type: "submit", disabled: disabled || submitting || status !== "ready", children: props.children ?? "Submit" });
|
|
177
|
+
}
|
|
178
|
+
function QuestionCancelButton(props) {
|
|
179
|
+
const { cancel, disabled, submitting } = useQuestionForm();
|
|
180
|
+
return /* @__PURE__ */ jsx("button", { ...props, type: "button", disabled: disabled || submitting, onClick: (event) => {
|
|
181
|
+
props.onClick?.(event);
|
|
182
|
+
if (!event.defaultPrevented) cancel();
|
|
183
|
+
}, children: props.children ?? "Cancel" });
|
|
184
|
+
}
|
|
185
|
+
var defaultRenderers = {
|
|
186
|
+
text: ({ field, value, disabled, describedBy, error, onChange, onBlur }) => field.type === "text" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
187
|
+
/* @__PURE__ */ jsx(FieldLabel, { htmlFor: field.name, children: label(field) }),
|
|
188
|
+
/* @__PURE__ */ jsx(Input, { id: field.name, name: field.name, value: typeof value === "string" ? value : "", placeholder: field.placeholder, minLength: field.minLength, maxLength: field.maxLength, pattern: field.pattern, disabled, "aria-describedby": describedBy || void 0, "aria-invalid": !!error, "aria-errormessage": error ? `${field.name}-error` : void 0, onChange: (e) => onChange(e.target.value), onBlur })
|
|
189
|
+
] }),
|
|
190
|
+
textarea: ({ field, value, disabled, describedBy, error, onChange, onBlur }) => field.type === "textarea" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
191
|
+
/* @__PURE__ */ jsx(FieldLabel, { htmlFor: field.name, children: label(field) }),
|
|
192
|
+
/* @__PURE__ */ jsx(Textarea, { id: field.name, name: field.name, value: typeof value === "string" ? value : "", placeholder: field.placeholder, minLength: field.minLength, maxLength: field.maxLength, disabled, "aria-describedby": describedBy || void 0, "aria-invalid": !!error, "aria-errormessage": error ? `${field.name}-error` : void 0, onChange: (e) => onChange(e.target.value), onBlur })
|
|
193
|
+
] }),
|
|
194
|
+
select: ({ field, value, disabled, describedBy, error, onChange, onBlur }) => (field.type === "select" || field.type === "radio") && /* @__PURE__ */ jsxs(ChoiceGroup, { "aria-describedby": field.type === "radio" ? describedBy || void 0 : void 0, "aria-invalid": field.type === "radio" ? !!error : void 0, "aria-errormessage": field.type === "radio" && error ? `${field.name}-error` : void 0, children: [
|
|
195
|
+
/* @__PURE__ */ jsx(ChoiceGroupLegend, { children: label(field) }),
|
|
196
|
+
field.options.map((option) => field.type === "select" ? null : /* @__PURE__ */ jsxs(ChoiceItem, { children: [
|
|
197
|
+
/* @__PURE__ */ jsx(Radio, { className: choiceControlClass, name: field.name, checked: value === option.value, disabled, onChange: () => onChange(option.value), onBlur }),
|
|
198
|
+
/* @__PURE__ */ jsxs(ChoiceItemBody, { children: [
|
|
199
|
+
/* @__PURE__ */ jsx(ChoiceItemTitle, { children: option.label }),
|
|
200
|
+
option.description ? /* @__PURE__ */ jsx(ChoiceItemDescription, { children: option.description }) : null
|
|
201
|
+
] })
|
|
202
|
+
] }, option.value)),
|
|
203
|
+
field.type === "select" ? /* @__PURE__ */ jsxs(Select, { name: field.name, value: typeof value === "string" ? value : "", disabled, onValueChange: (next) => onChange(next), children: [
|
|
204
|
+
/* @__PURE__ */ jsx(SelectTrigger, { className: "w-full", "aria-describedby": describedBy || void 0, "aria-invalid": !!error, "aria-errormessage": error ? `${field.name}-error` : void 0, onBlur, children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "Select\u2026" }) }),
|
|
205
|
+
/* @__PURE__ */ jsx(SelectContent, { children: field.options.map((o) => /* @__PURE__ */ jsx(SelectItem, { value: o.value, children: o.label }, o.value)) })
|
|
206
|
+
] }) : null
|
|
207
|
+
] }),
|
|
208
|
+
radio: (props) => defaultRenderers.select?.(props),
|
|
209
|
+
multiselect: ({ field, value, disabled, describedBy, error, onChange, onBlur }) => field.type === "multiselect" && /* @__PURE__ */ jsxs(ChoiceGroup, { "aria-describedby": describedBy || void 0, "aria-invalid": !!error, "aria-errormessage": error ? `${field.name}-error` : void 0, children: [
|
|
210
|
+
/* @__PURE__ */ jsx(ChoiceGroupLegend, { children: label(field) }),
|
|
211
|
+
field.options.map((option) => {
|
|
212
|
+
const list = Array.isArray(value) ? value : [];
|
|
213
|
+
return /* @__PURE__ */ jsxs(ChoiceItem, { children: [
|
|
214
|
+
/* @__PURE__ */ jsx(Checkbox, { className: choiceControlClass, name: field.name, checked: list.includes(option.value), disabled, onCheckedChange: (checked) => onChange(checked ? [...list, option.value] : list.filter((item) => item !== option.value)), onBlur }),
|
|
215
|
+
/* @__PURE__ */ jsxs(ChoiceItemBody, { children: [
|
|
216
|
+
/* @__PURE__ */ jsx(ChoiceItemTitle, { children: option.label }),
|
|
217
|
+
option.description ? /* @__PURE__ */ jsx(ChoiceItemDescription, { children: option.description }) : null
|
|
218
|
+
] })
|
|
219
|
+
] }, option.value);
|
|
220
|
+
})
|
|
221
|
+
] }),
|
|
222
|
+
checkbox: ({ field, value, disabled, describedBy, error, onChange, onBlur }) => field.type === "checkbox" && /* @__PURE__ */ jsxs(ChoiceItem, { children: [
|
|
223
|
+
/* @__PURE__ */ jsx(Checkbox, { className: choiceControlClass, name: field.name, checked: value === true, disabled, "aria-describedby": describedBy || void 0, "aria-invalid": !!error, "aria-errormessage": error ? `${field.name}-error` : void 0, onCheckedChange: (checked) => onChange(checked === true), onBlur }),
|
|
224
|
+
/* @__PURE__ */ jsx(ChoiceItemTitle, { children: field.label })
|
|
225
|
+
] }),
|
|
226
|
+
number: ({ field, value, disabled, describedBy, error, onChange, onBlur }) => field.type === "number" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
227
|
+
/* @__PURE__ */ jsx(FieldLabel, { htmlFor: field.name, children: label(field) }),
|
|
228
|
+
/* @__PURE__ */ jsx(Input, { id: field.name, type: "number", name: field.name, value: typeof value === "number" ? String(value) : "", min: field.min, max: field.max, step: field.integer ? 1 : field.step, disabled, "aria-describedby": describedBy || void 0, "aria-invalid": !!error, "aria-errormessage": error ? `${field.name}-error` : void 0, onChange: (e) => onChange(e.target.value === "" ? null : Number(e.target.value)), onBlur })
|
|
229
|
+
] }),
|
|
230
|
+
unsupported: UnsupportedFieldRenderer
|
|
231
|
+
};
|
|
232
|
+
function UnsupportedFieldRenderer({ field }) {
|
|
233
|
+
return /* @__PURE__ */ jsxs(FieldDescription, { role: "note", children: [
|
|
234
|
+
"Unsupported question field: ",
|
|
235
|
+
field.type
|
|
236
|
+
] });
|
|
237
|
+
}
|
|
238
|
+
function label(field) {
|
|
239
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
240
|
+
field.label,
|
|
241
|
+
"required" in field && field.required ? /* @__PURE__ */ jsx("span", { className: "text-destructive", "aria-hidden": "true", children: " *" }) : null
|
|
242
|
+
] });
|
|
243
|
+
}
|
|
244
|
+
function defaultsFor(schema, initial) {
|
|
245
|
+
const values = { ...initial ?? {} };
|
|
246
|
+
for (const field of schema?.fields ?? []) if (values[field.name] === void 0 && "defaultValue" in field) values[field.name] = field.defaultValue;
|
|
247
|
+
return values;
|
|
248
|
+
}
|
|
249
|
+
function validateQuestionValues(schema, values) {
|
|
250
|
+
const errors = {};
|
|
251
|
+
for (const field of schema.fields) {
|
|
252
|
+
const value = values[field.name];
|
|
253
|
+
const empty = value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
254
|
+
if ("required" in field && field.required && empty) {
|
|
255
|
+
errors[field.name] = "This field is required.";
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (empty) continue;
|
|
259
|
+
if (field.type === "text" || field.type === "textarea") {
|
|
260
|
+
if (typeof value !== "string") errors[field.name] = "Enter text.";
|
|
261
|
+
else if (field.minLength !== void 0 && value.length < field.minLength) errors[field.name] = `Must be at least ${field.minLength} characters.`;
|
|
262
|
+
else if (field.maxLength !== void 0 && value.length > field.maxLength) errors[field.name] = `Must be at most ${field.maxLength} characters.`;
|
|
263
|
+
else if (field.type === "text" && field.pattern && !new RegExp(field.pattern).test(value)) errors[field.name] = "Invalid format.";
|
|
264
|
+
} else if ((field.type === "select" || field.type === "radio") && (typeof value !== "string" || !field.options.some((o) => o.value === value))) errors[field.name] = "Choose a valid option.";
|
|
265
|
+
else if (field.type === "multiselect") {
|
|
266
|
+
if (!Array.isArray(value)) errors[field.name] = "Choose valid options.";
|
|
267
|
+
else if (field.minSelections !== void 0 && value.length < field.minSelections) errors[field.name] = `Choose at least ${field.minSelections}.`;
|
|
268
|
+
else if (field.maxSelections !== void 0 && value.length > field.maxSelections) errors[field.name] = `Choose at most ${field.maxSelections}.`;
|
|
269
|
+
else if (value.some((item) => !field.options.some((o) => o.value === item))) errors[field.name] = "Choose valid options.";
|
|
270
|
+
} else if (field.type === "checkbox" && typeof value !== "boolean") errors[field.name] = "Must be checked or unchecked.";
|
|
271
|
+
else if (field.type === "number") {
|
|
272
|
+
if (typeof value !== "number" || !Number.isFinite(value)) errors[field.name] = "Enter a number.";
|
|
273
|
+
else if (field.integer && !Number.isInteger(value)) errors[field.name] = "Enter a whole number.";
|
|
274
|
+
else if (field.min !== void 0 && value < field.min) errors[field.name] = `Must be at least ${field.min}.`;
|
|
275
|
+
else if (field.max !== void 0 && value > field.max) errors[field.name] = `Must be at most ${field.max}.`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { valid: Object.keys(errors).length === 0, errors };
|
|
279
|
+
}
|
|
280
|
+
function cssEscape(value) {
|
|
281
|
+
return globalThis.CSS?.escape?.(value) ?? value.replace(/[^A-Za-z0-9_-]/g, "\\$&");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/front/client.ts
|
|
285
|
+
var QuestionsClientError = class extends Error {
|
|
286
|
+
constructor(code, message, statusCode = 0) {
|
|
287
|
+
super(message);
|
|
288
|
+
this.code = code;
|
|
289
|
+
this.statusCode = statusCode;
|
|
290
|
+
}
|
|
291
|
+
code;
|
|
292
|
+
statusCode;
|
|
293
|
+
};
|
|
294
|
+
function readPendingQuestionFromState(state) {
|
|
295
|
+
const slot = state?.[ASK_USER_UI_STATE_SLOTS.PENDING];
|
|
296
|
+
if (!slot || typeof slot !== "object") return null;
|
|
297
|
+
const question = slot.question;
|
|
298
|
+
return question && typeof question === "object" ? question : null;
|
|
299
|
+
}
|
|
300
|
+
function createQuestionsClient(options = {}) {
|
|
301
|
+
async function dispatch(command) {
|
|
302
|
+
const response = await fetch(`${options.apiBaseUrl ?? ""}/api/v1/questions/commands`, {
|
|
303
|
+
method: "POST",
|
|
304
|
+
headers: { "Content-Type": "application/json", ...options.headers ?? {} },
|
|
305
|
+
body: JSON.stringify(command)
|
|
306
|
+
});
|
|
307
|
+
const payload = await response.json().catch(() => ({}));
|
|
308
|
+
if (!response.ok) throw new QuestionsClientError(payload.error ?? ASK_USER_ERROR_CODES.UI_UNAVAILABLE, payload.message ?? "Question command failed", response.status);
|
|
309
|
+
return payload;
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
dispatch,
|
|
313
|
+
cancel(question) {
|
|
314
|
+
return dispatch({ kind: ASK_USER_COMMAND_KINDS.CANCEL, params: { questionId: question.questionId, sessionId: question.sessionId, answerToken: question.answerToken } });
|
|
315
|
+
},
|
|
316
|
+
submit(question, values) {
|
|
317
|
+
if (!question.schema) throw new QuestionsClientError(ASK_USER_ERROR_CODES.QUESTION_NOT_READY, "Question is not ready");
|
|
318
|
+
const validation = validateQuestionValues(question.schema, values);
|
|
319
|
+
if (!validation.valid) throw new QuestionsClientError(ASK_USER_ERROR_CODES.ANSWER_INVALID, firstValidationMessage(validation));
|
|
320
|
+
return dispatch({ kind: ASK_USER_COMMAND_KINDS.SUBMIT, params: { questionId: question.questionId, sessionId: question.sessionId, answerToken: question.answerToken, values } });
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
function firstValidationMessage(validation) {
|
|
325
|
+
return Object.values(validation.errors)[0] ?? "Invalid answer";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// src/front/index.tsx
|
|
329
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
330
|
+
function createQuestionsStore() {
|
|
331
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
332
|
+
let pending = null;
|
|
333
|
+
return {
|
|
334
|
+
getPending: () => pending,
|
|
335
|
+
setPending(question) {
|
|
336
|
+
pending = question;
|
|
337
|
+
for (const listener of [...listeners]) listener();
|
|
338
|
+
},
|
|
339
|
+
subscribe(listener) {
|
|
340
|
+
listeners.add(listener);
|
|
341
|
+
return () => listeners.delete(listener);
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
var sharedQuestionsStore = createQuestionsStore();
|
|
346
|
+
var QuestionsRuntimeContext = createContext2(null);
|
|
347
|
+
function sessionScopedBlockerId(sessionId) {
|
|
348
|
+
return sessionId === "default" || sessionId === "anonymous" ? void 0 : sessionId;
|
|
349
|
+
}
|
|
350
|
+
function pendingQuestionSnapshot(store) {
|
|
351
|
+
const pending = store.getPending();
|
|
352
|
+
return pending ? `${pending.sessionId}:${pending.questionId}:${pending.status}` : "none";
|
|
353
|
+
}
|
|
354
|
+
function useQuestionsRuntime() {
|
|
355
|
+
const ctx = useContext2(QuestionsRuntimeContext);
|
|
356
|
+
if (!ctx) throw new Error("askUserPlugin QuestionsPane must be rendered under AskUserProvider");
|
|
357
|
+
return ctx;
|
|
358
|
+
}
|
|
359
|
+
function AskUserProvider({ apiBaseUrl, authHeaders, children }) {
|
|
360
|
+
const { addBlocker, removeBlocker } = useWorkspaceAttention();
|
|
361
|
+
const runtime = useMemo2(() => ({ ...sharedQuestionsStore, apiBaseUrl, authHeaders }), [apiBaseUrl, authHeaders]);
|
|
362
|
+
const pendingSnapshot = useSyncExternalStore(runtime.subscribe, () => pendingQuestionSnapshot(runtime), () => "none");
|
|
363
|
+
useEffect2(() => {
|
|
364
|
+
const pending = runtime.getPending();
|
|
365
|
+
const blockerId = pending ? `${ASK_USER_PLUGIN_ID}:${pending.sessionId}:${pending.questionId}` : null;
|
|
366
|
+
if (pending?.status === "ready" && blockerId) {
|
|
367
|
+
addBlocker({
|
|
368
|
+
id: blockerId,
|
|
369
|
+
reason: "waiting_for_user_input",
|
|
370
|
+
surfaceKind: ASK_USER_SURFACE_KIND,
|
|
371
|
+
target: pending.questionId,
|
|
372
|
+
label: "Answer the question in Questions to continue",
|
|
373
|
+
sessionId: sessionScopedBlockerId(pending.sessionId),
|
|
374
|
+
actions: [{ id: "open", label: "Open Questions" }]
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return () => {
|
|
378
|
+
if (blockerId) removeBlocker(blockerId);
|
|
379
|
+
};
|
|
380
|
+
}, [addBlocker, removeBlocker, runtime, pendingSnapshot]);
|
|
381
|
+
useEffect2(() => {
|
|
382
|
+
const onStop = (event) => {
|
|
383
|
+
const sessionId = event.detail?.sessionId;
|
|
384
|
+
const pending = runtime.getPending();
|
|
385
|
+
if (!pending || sessionId && sessionScopedBlockerId(pending.sessionId) && sessionId !== pending.sessionId) return;
|
|
386
|
+
runtime.setPending(null);
|
|
387
|
+
void createQuestionsClient({ apiBaseUrl: runtime.apiBaseUrl, headers: runtime.authHeaders }).cancel(pending).catch(() => void 0);
|
|
388
|
+
};
|
|
389
|
+
window.addEventListener("boring:workspace-composer-stop", onStop);
|
|
390
|
+
return () => window.removeEventListener("boring:workspace-composer-stop", onStop);
|
|
391
|
+
}, [runtime]);
|
|
392
|
+
useEffect2(() => {
|
|
393
|
+
let stopped = false;
|
|
394
|
+
async function refreshPending() {
|
|
395
|
+
try {
|
|
396
|
+
const response = await fetch(`${apiBaseUrl}/api/v1/ui/state`, { headers: authHeaders });
|
|
397
|
+
const state = await response.json().catch(() => null);
|
|
398
|
+
if (!stopped) runtime.setPending(readPendingQuestionFromState(state));
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const onVisibility = () => {
|
|
403
|
+
if (document.visibilityState === "visible") void refreshPending();
|
|
404
|
+
};
|
|
405
|
+
const onUiCommand = () => {
|
|
406
|
+
void refreshPending();
|
|
407
|
+
};
|
|
408
|
+
void refreshPending();
|
|
409
|
+
window.addEventListener("focus", refreshPending);
|
|
410
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
411
|
+
window.addEventListener(UI_COMMAND_EVENT, onUiCommand);
|
|
412
|
+
return () => {
|
|
413
|
+
stopped = true;
|
|
414
|
+
window.removeEventListener("focus", refreshPending);
|
|
415
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
416
|
+
window.removeEventListener(UI_COMMAND_EVENT, onUiCommand);
|
|
417
|
+
};
|
|
418
|
+
}, [apiBaseUrl, authHeaders, runtime]);
|
|
419
|
+
return /* @__PURE__ */ jsx2(QuestionsRuntimeContext.Provider, { value: runtime, children });
|
|
420
|
+
}
|
|
421
|
+
function QuestionsPane({ api, params, className }) {
|
|
422
|
+
const runtime = useQuestionsRuntime();
|
|
423
|
+
const pending = useSyncExternalStore(runtime.subscribe, runtime.getPending, runtime.getPending);
|
|
424
|
+
const [closedQuestionId, setClosedQuestionId] = useState2(null);
|
|
425
|
+
const [error, setError] = useState2(null);
|
|
426
|
+
const [submitting, setSubmitting] = useState2(false);
|
|
427
|
+
const paramQuestion = params?.question;
|
|
428
|
+
const question = pending ?? (paramQuestion?.questionId === closedQuestionId ? null : paramQuestion) ?? null;
|
|
429
|
+
const client = useMemo2(() => createQuestionsClient({ apiBaseUrl: runtime.apiBaseUrl, headers: runtime.authHeaders }), [runtime.apiBaseUrl, runtime.authHeaders]);
|
|
430
|
+
useEffect2(() => {
|
|
431
|
+
const onStop = (event) => {
|
|
432
|
+
const sessionId = event.detail?.sessionId;
|
|
433
|
+
if (!question || sessionId && sessionScopedBlockerId(question.sessionId) && sessionId !== question.sessionId) return;
|
|
434
|
+
setClosedQuestionId(question.questionId);
|
|
435
|
+
runtime.setPending(null);
|
|
436
|
+
api.close();
|
|
437
|
+
};
|
|
438
|
+
window.addEventListener("boring:workspace-composer-stop", onStop);
|
|
439
|
+
return () => window.removeEventListener("boring:workspace-composer-stop", onStop);
|
|
440
|
+
}, [api, question, runtime]);
|
|
441
|
+
useEffect2(() => {
|
|
442
|
+
if (question && pending === null && !paramQuestion) api.close();
|
|
443
|
+
}, [api, pending, paramQuestion, question]);
|
|
444
|
+
return /* @__PURE__ */ jsx2("div", { className: className ?? "h-full", children: /* @__PURE__ */ jsxs2(Pane, { className: "h-full border-0 bg-background text-sm", children: [
|
|
445
|
+
/* @__PURE__ */ jsx2(PaneHeader, { className: "border-b bg-background/95", children: /* @__PURE__ */ jsx2("div", { children: /* @__PURE__ */ jsxs2(PaneTitle, { className: "flex items-center gap-2", children: [
|
|
446
|
+
/* @__PURE__ */ jsx2(HelpCircle, { className: "h-4 w-4 text-muted-foreground" }),
|
|
447
|
+
" Agent needs input"
|
|
448
|
+
] }) }) }),
|
|
449
|
+
!question ? /* @__PURE__ */ jsx2(PaneBody, { className: "overflow-auto p-4", children: /* @__PURE__ */ jsx2(EmptyState, { icon: /* @__PURE__ */ jsx2(HelpCircle, { className: "h-5 w-5" }), title: "No pending questions", description: "When the agent needs a decision, the form will appear here.", className: "border border-dashed bg-muted/20" }) }) : null,
|
|
450
|
+
question?.status === "ready" && question.schema ? /* @__PURE__ */ jsx2(QuestionFormProvider, { schema: question.schema, submitting, onSubmit: async (values) => {
|
|
451
|
+
setSubmitting(true);
|
|
452
|
+
setError(null);
|
|
453
|
+
try {
|
|
454
|
+
await client.submit(question, values);
|
|
455
|
+
setClosedQuestionId(question.questionId);
|
|
456
|
+
runtime.setPending(null);
|
|
457
|
+
api.close();
|
|
458
|
+
params?.__closeWorkbenchOnDone?.();
|
|
459
|
+
} catch (err) {
|
|
460
|
+
setError(err instanceof QuestionsClientError ? err.message : String(err));
|
|
461
|
+
} finally {
|
|
462
|
+
setSubmitting(false);
|
|
463
|
+
}
|
|
464
|
+
}, onCancel: async () => {
|
|
465
|
+
setSubmitting(true);
|
|
466
|
+
setError(null);
|
|
467
|
+
try {
|
|
468
|
+
await client.cancel(question);
|
|
469
|
+
setClosedQuestionId(question.questionId);
|
|
470
|
+
runtime.setPending(null);
|
|
471
|
+
api.close();
|
|
472
|
+
params?.__closeWorkbenchOnDone?.();
|
|
473
|
+
} catch (err) {
|
|
474
|
+
setError(err instanceof QuestionsClientError ? err.message : String(err));
|
|
475
|
+
} finally {
|
|
476
|
+
setSubmitting(false);
|
|
477
|
+
}
|
|
478
|
+
}, children: /* @__PURE__ */ jsxs2(QuestionForm, { children: [
|
|
479
|
+
/* @__PURE__ */ jsx2(PaneBody, { className: "overflow-auto p-4", children: /* @__PURE__ */ jsxs2("div", { className: "space-y-4", children: [
|
|
480
|
+
/* @__PURE__ */ jsxs2("section", { className: "rounded-md border border-border/60 bg-muted/30 p-4", children: [
|
|
481
|
+
/* @__PURE__ */ jsx2("div", { className: "text-xs font-medium uppercase tracking-wide text-muted-foreground", children: "Waiting for answer" }),
|
|
482
|
+
/* @__PURE__ */ jsx2("h2", { className: "mt-2 text-balance text-sm font-semibold leading-5 text-foreground", children: question.title ?? "Question" }),
|
|
483
|
+
question.context ? /* @__PURE__ */ jsx2("p", { className: "mt-2 max-w-prose text-sm leading-6 text-muted-foreground", children: question.context }) : null
|
|
484
|
+
] }),
|
|
485
|
+
/* @__PURE__ */ jsx2("div", { className: "space-y-4", children: /* @__PURE__ */ jsx2(QuestionFields, {}) }),
|
|
486
|
+
error ? /* @__PURE__ */ jsx2(Notice, { tone: "destructive", role: "alert", children: error }) : null
|
|
487
|
+
] }) }),
|
|
488
|
+
/* @__PURE__ */ jsxs2(PaneFooter, { className: "justify-between border-t bg-background px-4 py-3", children: [
|
|
489
|
+
/* @__PURE__ */ jsx2("p", { className: "min-w-0 text-xs text-muted-foreground", children: "Sends answers and closes the pane." }),
|
|
490
|
+
/* @__PURE__ */ jsxs2("div", { className: "flex gap-2", children: [
|
|
491
|
+
/* @__PURE__ */ jsx2(Button, { asChild: true, variant: "outline", size: "sm", children: /* @__PURE__ */ jsx2(QuestionCancelButton, { children: "Cancel" }) }),
|
|
492
|
+
/* @__PURE__ */ jsx2(Button, { asChild: true, size: "sm", children: /* @__PURE__ */ jsx2(QuestionSubmitButton, { children: question.schema.submitLabel ?? "Send answers" }) })
|
|
493
|
+
] })
|
|
494
|
+
] })
|
|
495
|
+
] }) }) : null,
|
|
496
|
+
question && question.status !== "ready" ? /* @__PURE__ */ jsx2(PaneBody, { className: "p-5", children: /* @__PURE__ */ jsx2(Notice, { children: /* @__PURE__ */ jsxs2("span", { className: "flex items-center gap-2", children: [
|
|
497
|
+
/* @__PURE__ */ jsx2(XCircle, { className: "h-4 w-4 text-muted-foreground" }),
|
|
498
|
+
"Question ",
|
|
499
|
+
question.status
|
|
500
|
+
] }) }) }) : null
|
|
501
|
+
] }) });
|
|
502
|
+
}
|
|
503
|
+
var askUserPlugin = definePlugin({
|
|
504
|
+
id: ASK_USER_PLUGIN_ID,
|
|
505
|
+
label: ASK_USER_PANEL_TITLE,
|
|
506
|
+
providers: [
|
|
507
|
+
{
|
|
508
|
+
id: `${ASK_USER_PLUGIN_ID}.provider`,
|
|
509
|
+
component: AskUserProvider
|
|
510
|
+
}
|
|
511
|
+
],
|
|
512
|
+
panels: [
|
|
513
|
+
{
|
|
514
|
+
id: ASK_USER_PANEL_ID,
|
|
515
|
+
label: ASK_USER_PANEL_TITLE,
|
|
516
|
+
icon: HelpCircle,
|
|
517
|
+
component: QuestionsPane,
|
|
518
|
+
placement: "center",
|
|
519
|
+
source: "builtin",
|
|
520
|
+
chromeless: true
|
|
521
|
+
}
|
|
522
|
+
],
|
|
523
|
+
surfaceResolvers: [
|
|
524
|
+
{
|
|
525
|
+
id: `${ASK_USER_PLUGIN_ID}.surface`,
|
|
526
|
+
kind: ASK_USER_SURFACE_KIND,
|
|
527
|
+
source: "builtin",
|
|
528
|
+
// No inner kind guard — the workspace's surface registry already
|
|
529
|
+
// pre-filters by the top-level `kind` field before calling resolve.
|
|
530
|
+
resolve(request) {
|
|
531
|
+
const metaQuestion = typeof request.meta === "object" && request.meta && "question" in request.meta ? request.meta.question : void 0;
|
|
532
|
+
return {
|
|
533
|
+
component: ASK_USER_PANEL_ID,
|
|
534
|
+
id: ASK_USER_PANEL_ID,
|
|
535
|
+
title: ASK_USER_PANEL_TITLE,
|
|
536
|
+
params: { questionId: request.target, question: metaQuestion }
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
]
|
|
541
|
+
});
|
|
542
|
+
var front_default = askUserPlugin;
|
|
543
|
+
export {
|
|
544
|
+
askUserPlugin,
|
|
545
|
+
front_default as default
|
|
546
|
+
};
|