@alexkroman1/aai-ui 6.7.1 → 6.7.2

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Whether a form's own fields exist yet.
3
+ *
4
+ * `<Form>` leans entirely on NATIVE validation — it is a real `<form>` with no
5
+ * `noValidate`, so a `required` field is what stops an empty submit, and the
6
+ * module doc next door says so. That has one hole, and it is the whole reason
7
+ * this exists: a field set declared REMOTELY is not in the DOM while its
8
+ * declaration is in flight, so there is nothing for the browser to validate and
9
+ * an empty submit sails through.
10
+ *
11
+ * It is not theoretical. `<WorkflowFields>` renders `null` until the workflow
12
+ * listing lands, so the transcription desk's first click — before the one-request
13
+ * lookup answered — submitted a form holding only its button. The browser was
14
+ * happy, the payload was `{}`, and the run was refused by the agent with
15
+ * `Invalid input for workflow "transcribeStream": recording: Invalid input`: a
16
+ * schema complaint about a field the person had not been shown, naming a workflow
17
+ * they did not choose by name, for a file picker that appeared a moment later.
18
+ *
19
+ * ## Readiness is DECLARED by the children, because only they know
20
+ *
21
+ * `Form` cannot ask. It renders `{children}` and reads the DOM on submit, and a
22
+ * pending fetch leaves no trace in the DOM at all — which is exactly the
23
+ * difference from `data-aai-read`, the other thing a child tells the form: that
24
+ * one describes an element that EXISTS. So this is a context rather than an
25
+ * attribute, and it carries the one fact a DOM read cannot recover.
26
+ *
27
+ * A form with no such children is ready by definition — `useFormFieldsPending`
28
+ * outside a provider reports nothing pending, so every hand-written form is
29
+ * unaffected and `Form` keeps working outside this package.
30
+ */
31
+ /** What a child calls to say whether its own fields are ready. */
32
+ type Readiness = (key: string, pending: boolean) => void;
33
+ export declare const FormReadinessProvider: import("react").Provider<Readiness | undefined>;
34
+ /**
35
+ * Track which children are still waiting for their fields.
36
+ *
37
+ * A SET keyed by the child's own `useId`, not a counter: a child that re-renders
38
+ * while pending must not increment twice, and one that unmounts mid-flight must
39
+ * not leave the form disabled forever. Both are the ordinary lifecycle here — a
40
+ * page that switches workflows swaps one `<WorkflowFields>` for another.
41
+ */
42
+ export declare function useFormReadiness(): {
43
+ pending: boolean;
44
+ declare: Readiness;
45
+ };
46
+ /**
47
+ * Declare from a child that its fields are, or are no longer, still loading.
48
+ *
49
+ * Reports through an EFFECT rather than during render, because this writes to a
50
+ * parent's state — doing it in the render body is the "cannot update a component
51
+ * while rendering a different component" warning, and under StrictMode it is a
52
+ * double report the parent has to be idempotent about anyway. The cleanup
53
+ * releases the claim, so an unmounted field set never holds the form shut.
54
+ */
55
+ export declare function useDeclareFieldsPending(pending: boolean): void;
56
+ export {};
package/dist/index.js CHANGED
@@ -14,12 +14,98 @@ import { client, mountRoot, resolveContainer } from "./define-client.js";
14
14
  import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
15
15
  import clsx from "clsx";
16
16
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
17
- import { createElement, useCallback, useEffect, useId, useRef, useState } from "react";
17
+ import { createContext, createElement, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
18
18
  import { errorMessage, isTerminal, isTerminal as isTerminal$1 } from "@alexkroman1/aai";
19
19
  import { isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
20
20
  import { createWorkflowApiClient } from "@alexkroman1/aai/workflow-api";
21
21
  import { createParser } from "eventsource-parser";
22
22
  import { createEpoch } from "@alexkroman1/aai/internal";
23
+ //#region components/_form-readiness.ts
24
+ /**
25
+ * Whether a form's own fields exist yet.
26
+ *
27
+ * `<Form>` leans entirely on NATIVE validation — it is a real `<form>` with no
28
+ * `noValidate`, so a `required` field is what stops an empty submit, and the
29
+ * module doc next door says so. That has one hole, and it is the whole reason
30
+ * this exists: a field set declared REMOTELY is not in the DOM while its
31
+ * declaration is in flight, so there is nothing for the browser to validate and
32
+ * an empty submit sails through.
33
+ *
34
+ * It is not theoretical. `<WorkflowFields>` renders `null` until the workflow
35
+ * listing lands, so the transcription desk's first click — before the one-request
36
+ * lookup answered — submitted a form holding only its button. The browser was
37
+ * happy, the payload was `{}`, and the run was refused by the agent with
38
+ * `Invalid input for workflow "transcribeStream": recording: Invalid input`: a
39
+ * schema complaint about a field the person had not been shown, naming a workflow
40
+ * they did not choose by name, for a file picker that appeared a moment later.
41
+ *
42
+ * ## Readiness is DECLARED by the children, because only they know
43
+ *
44
+ * `Form` cannot ask. It renders `{children}` and reads the DOM on submit, and a
45
+ * pending fetch leaves no trace in the DOM at all — which is exactly the
46
+ * difference from `data-aai-read`, the other thing a child tells the form: that
47
+ * one describes an element that EXISTS. So this is a context rather than an
48
+ * attribute, and it carries the one fact a DOM read cannot recover.
49
+ *
50
+ * A form with no such children is ready by definition — `useFormFieldsPending`
51
+ * outside a provider reports nothing pending, so every hand-written form is
52
+ * unaffected and `Form` keeps working outside this package.
53
+ */
54
+ /**
55
+ * Set by `Form`, read by any field set that fetches its own declaration.
56
+ *
57
+ * `undefined` means no `Form` above, which is legal: the fields render, they are
58
+ * simply not gating anything.
59
+ */
60
+ const FormReadinessContext = createContext(void 0);
61
+ const FormReadinessProvider = FormReadinessContext.Provider;
62
+ /**
63
+ * Track which children are still waiting for their fields.
64
+ *
65
+ * A SET keyed by the child's own `useId`, not a counter: a child that re-renders
66
+ * while pending must not increment twice, and one that unmounts mid-flight must
67
+ * not leave the form disabled forever. Both are the ordinary lifecycle here — a
68
+ * page that switches workflows swaps one `<WorkflowFields>` for another.
69
+ */
70
+ function useFormReadiness() {
71
+ const [waiting, setWaiting] = useState(() => /* @__PURE__ */ new Set());
72
+ const declare = useCallback((key, pending) => {
73
+ setWaiting((held) => {
74
+ if (pending === held.has(key)) return held;
75
+ const next = new Set(held);
76
+ if (pending) next.add(key);
77
+ else next.delete(key);
78
+ return next;
79
+ });
80
+ }, []);
81
+ return {
82
+ pending: waiting.size > 0,
83
+ declare
84
+ };
85
+ }
86
+ /**
87
+ * Declare from a child that its fields are, or are no longer, still loading.
88
+ *
89
+ * Reports through an EFFECT rather than during render, because this writes to a
90
+ * parent's state — doing it in the render body is the "cannot update a component
91
+ * while rendering a different component" warning, and under StrictMode it is a
92
+ * double report the parent has to be idempotent about anyway. The cleanup
93
+ * releases the claim, so an unmounted field set never holds the form shut.
94
+ */
95
+ function useDeclareFieldsPending(pending) {
96
+ const declare = useContext(FormReadinessContext);
97
+ const key = useId();
98
+ useEffect(() => {
99
+ if (!declare) return;
100
+ declare(key, pending);
101
+ return () => declare(key, false);
102
+ }, [
103
+ declare,
104
+ key,
105
+ pending
106
+ ]);
107
+ }
108
+ //#endregion
23
109
  //#region components/_form-values.ts
24
110
  /**
25
111
  * Read one `<form>`'s named controls into a plain object.
@@ -172,10 +258,11 @@ function dataUrl(file) {
172
258
  function Form({ onSubmit, error, children, className, ...rest }) {
173
259
  const theme = useTheme();
174
260
  const [busy, setBusy] = useState(false);
261
+ const { pending: fieldsPending, declare } = useFormReadiness();
175
262
  return /* @__PURE__ */ jsxs("form", {
176
263
  onSubmit: useCallback((event) => {
177
264
  event.preventDefault();
178
- if (busy) return;
265
+ if (busy || fieldsPending) return;
179
266
  const form = event.currentTarget;
180
267
  setBusy(true);
181
268
  (async () => {
@@ -185,13 +272,20 @@ function Form({ onSubmit, error, children, className, ...rest }) {
185
272
  setBusy(false);
186
273
  }
187
274
  })();
188
- }, [busy, onSubmit]),
275
+ }, [
276
+ busy,
277
+ fieldsPending,
278
+ onSubmit
279
+ ]),
189
280
  className: clsx("flex flex-col gap-5 font-aai", className),
190
281
  ...rest,
191
282
  children: [/* @__PURE__ */ jsx("fieldset", {
192
- disabled: busy,
283
+ disabled: busy || fieldsPending,
193
284
  className: "contents",
194
- children
285
+ children: /* @__PURE__ */ jsx(FormReadinessProvider, {
286
+ value: declare,
287
+ children
288
+ })
195
289
  }), error !== void 0 && error !== "" && /* @__PURE__ */ jsx("p", {
196
290
  role: "alert",
197
291
  className: "text-sm",
@@ -1234,8 +1328,9 @@ function useWorkflowSubmit(workflow, opts = {}) {
1234
1328
  * @public
1235
1329
  */
1236
1330
  function WorkflowFields({ workflow }) {
1237
- const { workflows } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1331
+ const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1238
1332
  const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
1333
+ useDeclareFieldsPending(loading);
1239
1334
  const schema = asObjectSchema(summary?.inputSchema);
1240
1335
  if (!schema?.properties) return null;
1241
1336
  const required = new Set(schema.required ?? []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "6.7.1",
3
+ "version": "6.7.2",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -29,7 +29,7 @@
29
29
  "remark-gfm": "^4.0.1",
30
30
  "use-stick-to-bottom": "^1.1.6",
31
31
  "use-sync-external-store": "^1.6.0",
32
- "@alexkroman1/aai": "6.7.1"
32
+ "@alexkroman1/aai": "6.7.2"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^19.0.0",