@alexkroman1/aai-ui 6.7.1 → 6.8.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/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",
@@ -496,6 +590,14 @@ function formatBytes(bytes) {
496
590
  * - **The file is NAMED, and counted when there is more than one.** Files are
497
591
  * sent one after another, so a single bar otherwise appears to restart from
498
592
  * zero partway through with nothing to say why.
593
+ * - **A paused upload SAYS SO, rather than being a bar that stopped.** Those look
594
+ * identical, which is the whole reason `UploadStatus.paused` exists, and the
595
+ * fill stops animating so the difference is visible without reading.
596
+ *
597
+ * The pause control appears only when a handler for it is passed. That is not
598
+ * politeness about props: a button whose press does nothing is worse than no
599
+ * button, and a page holding an `upload` it did not produce (a saved status, a
600
+ * parent's state) has nothing to pause.
499
601
  *
500
602
  * @example
501
603
  * ```tsx
@@ -515,16 +617,20 @@ function formatBytes(bytes) {
515
617
  *
516
618
  * @param upload - What `useWorkflowSubmit` reports. `undefined` renders nothing,
517
619
  * so a page may pass its state straight through.
620
+ * @param onPause - The hook's `pauseUpload`. Pass it together with `onResume` to
621
+ * get the control; pass neither for a bar that only reports.
622
+ * @param onResume - The hook's `resumeUpload`.
518
623
  * @param className - Replaces the default classes rather than extending them,
519
624
  * so a custom chrome is not fighting a default it did not ask for.
520
625
  *
521
626
  * @public
522
627
  */
523
- function UploadProgressBar({ upload, className }) {
628
+ function UploadProgressBar({ upload, onPause, onResume, className }) {
524
629
  const theme = useTheme();
525
630
  const labelId = useId();
526
631
  if (!upload) return null;
527
- const { name, index, count, loaded, total, fraction } = upload;
632
+ const { name, index, count, loaded, total, fraction, paused } = upload;
633
+ const control = onPause && onResume ? paused ? onResume : onPause : void 0;
528
634
  const percent = fraction === void 0 ? void 0 : Math.round(fraction * 100);
529
635
  const faint = inkTint(theme.text, theme.surface, 65);
530
636
  return /* @__PURE__ */ jsxs("div", {
@@ -535,11 +641,20 @@ function UploadProgressBar({ upload, className }) {
535
641
  id: labelId,
536
642
  className: "truncate",
537
643
  style: { color: faint },
538
- children: count > 1 ? `Uploading ${name} (${index} of ${count})` : `Uploading ${name}`
539
- }), /* @__PURE__ */ jsx("span", {
540
- className: "shrink-0 tabular-nums",
541
- style: { color: faint },
542
- children: total === void 0 ? formatBytes(loaded) : `${formatBytes(loaded)} of ${formatBytes(total)}`
644
+ children: `${paused ? "Paused" : "Uploading"} ${name}${count > 1 ? ` (${index} of ${count})` : ""}`
645
+ }), /* @__PURE__ */ jsxs("div", {
646
+ className: "flex shrink-0 items-baseline gap-3",
647
+ children: [/* @__PURE__ */ jsx("span", {
648
+ className: "tabular-nums",
649
+ style: { color: faint },
650
+ children: total === void 0 ? formatBytes(loaded) : `${formatBytes(loaded)} of ${formatBytes(total)}`
651
+ }), control && /* @__PURE__ */ jsx(Button, {
652
+ type: "button",
653
+ variant: "ghost",
654
+ className: "h-6 px-2 text-[0.625rem]",
655
+ onClick: control,
656
+ children: paused ? "Resume" : "Pause"
657
+ })]
543
658
  })]
544
659
  }), /* @__PURE__ */ jsx("div", {
545
660
  role: "progressbar",
@@ -550,7 +665,7 @@ function UploadProgressBar({ upload, className }) {
550
665
  className: "h-1.5 w-full overflow-hidden rounded-full",
551
666
  style: { backgroundColor: inkTint(theme.text, theme.surface, TRACK_TINT_PCT) },
552
667
  children: /* @__PURE__ */ jsx("div", {
553
- className: clsx("h-full rounded-full transition-[width] duration-200 ease-out", percent === void 0 && "animate-pulse"),
668
+ className: clsx("h-full rounded-full transition-[width] duration-200 ease-out", percent === void 0 && !paused && "animate-pulse"),
554
669
  style: {
555
670
  backgroundColor: theme.primary,
556
671
  width: percent === void 0 ? "100%" : `${percent}%`
@@ -560,6 +675,243 @@ function UploadProgressBar({ upload, className }) {
560
675
  });
561
676
  }
562
677
  //#endregion
678
+ //#region _upload-session.ts
679
+ /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
680
+ function isAbortError(err) {
681
+ return err instanceof Error && err.name === "AbortError";
682
+ }
683
+ /** A fresh upload id: a capability, so it is random rather than derived. */
684
+ function randomUploadId() {
685
+ return crypto.randomUUID().replaceAll("-", "");
686
+ }
687
+ /**
688
+ * Send one file, waiting out however many pauses the person takes.
689
+ *
690
+ * The loop from the module doc, written once: both hooks need exactly this and a
691
+ * second copy of it is a second place for the abort/pause distinction to be got
692
+ * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
693
+ * false the first time, since a fresh id has nothing to resume and saying
694
+ * otherwise waives the refusal that makes a caller-chosen id safe.
695
+ *
696
+ * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
697
+ * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
698
+ * how an abandoned submission unwinds without being reported as a failure.
699
+ */
700
+ async function sendThroughGate(gate, send) {
701
+ let tried = false;
702
+ for (;;) {
703
+ await gate.settle();
704
+ if (gate.cancelled) throw new Error("Upload cancelled.");
705
+ const resume = tried;
706
+ tried = true;
707
+ try {
708
+ await send(resume);
709
+ return;
710
+ } catch (err) {
711
+ if (gate.cancelled || !isAbortError(err)) throw err;
712
+ }
713
+ }
714
+ }
715
+ /**
716
+ * A gate, open.
717
+ *
718
+ * One per upload rather than one per hook: the id and the windows already stored
719
+ * belong to a file, so a gate that outlived its file would resume something else.
720
+ */
721
+ function createUploadGate() {
722
+ let controller = new AbortController();
723
+ let paused = false;
724
+ let cancelled = false;
725
+ let open;
726
+ let closed;
727
+ return {
728
+ get paused() {
729
+ return paused;
730
+ },
731
+ get cancelled() {
732
+ return cancelled;
733
+ },
734
+ get signal() {
735
+ return controller.signal;
736
+ },
737
+ pause() {
738
+ if (paused || cancelled) return;
739
+ paused = true;
740
+ const gate = Promise.withResolvers();
741
+ closed = gate.promise;
742
+ open = gate.resolve;
743
+ controller.abort();
744
+ },
745
+ resume() {
746
+ if (!paused || cancelled) return;
747
+ paused = false;
748
+ controller = new AbortController();
749
+ open?.();
750
+ open = void 0;
751
+ closed = void 0;
752
+ },
753
+ cancel() {
754
+ if (cancelled) return;
755
+ cancelled = true;
756
+ paused = false;
757
+ controller.abort();
758
+ open?.();
759
+ open = void 0;
760
+ closed = void 0;
761
+ },
762
+ async settle() {
763
+ if (closed) await closed;
764
+ }
765
+ };
766
+ }
767
+ //#endregion
768
+ //#region _workflow-files.ts
769
+ /**
770
+ * Which of a submitted form's values are FILES.
771
+ *
772
+ * Its own module because both submit hooks need the identical answer and then do
773
+ * two different things with it — `useWorkflowSubmit` stores each file and passes
774
+ * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
775
+ * A second copy of this predicate would be a form field that one hook treats as a
776
+ * file and the other does not, which is invisible until the run reads the wrong
777
+ * kind of string.
778
+ */
779
+ /**
780
+ * The files a submitted field carries, if that is what it carries.
781
+ *
782
+ * An array counts only when it is files ALL the way through — a mixed array is
783
+ * some other field's value that happens to contain one, and turning half of it
784
+ * into ids would corrupt it silently.
785
+ */
786
+ function filesOf(value) {
787
+ if (value instanceof File) return [value];
788
+ if (!Array.isArray(value)) return [];
789
+ const files = value.filter((one) => one instanceof File);
790
+ return files.length > 0 && files.length === value.length ? files : [];
791
+ }
792
+ /**
793
+ * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
794
+ * being sent.
795
+ *
796
+ * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
797
+ * own enumerable properties. So a File left in a payload does not fail to send: it
798
+ * arrives as an empty object, and the workflow rejects it against whatever its own
799
+ * schema says the property should be. Measured in production as
800
+ * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
801
+ * about a type, on a form where the user had picked a perfectly good file.
802
+ *
803
+ * Exported beside {@link filesOf} because it is the same question asked at the
804
+ * other end: that one decides which fields to UPLOAD, this one checks that none
805
+ * were missed. Both hooks are the callers.
806
+ */
807
+ function fileFields(input) {
808
+ if (!isRecord(input)) return [];
809
+ return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
810
+ }
811
+ //#endregion
812
+ //#region _upload-files.ts
813
+ /**
814
+ * Turning a form's `File`s into stored upload ids, pauses and all.
815
+ *
816
+ * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
817
+ * real one: that module is the two HOOKS and the state between them, where this
818
+ * is the walk over a submitted input — which is the only part of it that knows
819
+ * what a `File` is, holds a loop, and survives being re-entered.
820
+ *
821
+ * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
822
+ * one file rather than walking an input and shares only the gate underneath both
823
+ * (`_upload-session.ts`).
824
+ */
825
+ /** A fresh session for one submission. */
826
+ function createUploadSession() {
827
+ return {
828
+ ids: /* @__PURE__ */ new Map(),
829
+ stored: /* @__PURE__ */ new Map(),
830
+ tried: /* @__PURE__ */ new Set(),
831
+ gate: createUploadGate()
832
+ };
833
+ }
834
+ /**
835
+ * Replace every `File` in a submitted form with the id of a stored upload,
836
+ * reporting how far each one has got.
837
+ *
838
+ * Sequential rather than `Promise.all`: these are large bodies, and a form with
839
+ * two 200 MB recordings should send them one after another rather than compete
840
+ * for the same connection. That is also what makes a single bar honest — one
841
+ * file is in flight at a time, and `index`/`count` say which.
842
+ *
843
+ * Anything that is not a `File` (or an array of them) passes through untouched,
844
+ * so this is invisible to every form that has none — including one whose values
845
+ * are not an object at all, which `submit` accepts.
846
+ *
847
+ * ## `uploadStream`, not `upload`, and the id is the reason
848
+ *
849
+ * The difference between the two calls is only who mints the id — and that is
850
+ * exactly what decides whether an interrupted upload can be picked up again. An
851
+ * `upload` mints its own at the END and hands it back, so a caller whose upload
852
+ * died has nothing to name what was stored and no choice but to send the file
853
+ * again. A `uploadStream` is told the id up front, so the windows already in the
854
+ * store are addressable, which is what both a pause and a server restart need.
855
+ *
856
+ * Nothing else about the submission changes: the run is still started after the
857
+ * last byte lands, so the incomplete record a streamed upload leaves along the
858
+ * way is one nobody reads.
859
+ */
860
+ async function uploadFiles(api, input, report, parallel, session) {
861
+ if (!isRecord(input)) return input;
862
+ const entries = Object.entries(input);
863
+ const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
864
+ let index = 0;
865
+ const store = async (file) => {
866
+ index += 1;
867
+ const done = session.stored.get(file);
868
+ if (done !== void 0) return done;
869
+ let id = session.ids.get(file);
870
+ if (id === void 0) {
871
+ id = randomUploadId();
872
+ session.ids.set(file, id);
873
+ }
874
+ const position = {
875
+ name: file.name,
876
+ index,
877
+ count
878
+ };
879
+ await sendThroughGate(session.gate, async (resume) => {
880
+ await api.uploadStream(id, file, {
881
+ name: file.name,
882
+ signal: session.gate.signal,
883
+ onProgress: (progress) => report({
884
+ ...position,
885
+ ...progress,
886
+ paused: session.gate.paused
887
+ }),
888
+ ...omitUndefined({
889
+ parallel,
890
+ resume: resume ? true : void 0
891
+ })
892
+ });
893
+ });
894
+ session.stored.set(file, id);
895
+ return id;
896
+ };
897
+ const out = {};
898
+ for (const [name, value] of entries) {
899
+ if (value instanceof File) {
900
+ out[name] = await store(value);
901
+ continue;
902
+ }
903
+ const chosen = filesOf(value);
904
+ if (chosen.length === 0) {
905
+ out[name] = value;
906
+ continue;
907
+ }
908
+ const ids = [];
909
+ for (const file of chosen) ids.push(await store(file));
910
+ out[name] = ids;
911
+ }
912
+ return out;
913
+ }
914
+ //#endregion
563
915
  //#region workflow-client.ts
564
916
  /**
565
917
  * Create a workflow API client aimed at the agent serving this page.
@@ -626,50 +978,6 @@ function useWorkflowApiRef(api) {
626
978
  }, []);
627
979
  }
628
980
  //#endregion
629
- //#region _workflow-files.ts
630
- /**
631
- * Which of a submitted form's values are FILES.
632
- *
633
- * Its own module because both submit hooks need the identical answer and then do
634
- * two different things with it — `useWorkflowSubmit` stores each file and passes
635
- * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
636
- * A second copy of this predicate would be a form field that one hook treats as a
637
- * file and the other does not, which is invisible until the run reads the wrong
638
- * kind of string.
639
- */
640
- /**
641
- * The files a submitted field carries, if that is what it carries.
642
- *
643
- * An array counts only when it is files ALL the way through — a mixed array is
644
- * some other field's value that happens to contain one, and turning half of it
645
- * into ids would corrupt it silently.
646
- */
647
- function filesOf(value) {
648
- if (value instanceof File) return [value];
649
- if (!Array.isArray(value)) return [];
650
- const files = value.filter((one) => one instanceof File);
651
- return files.length > 0 && files.length === value.length ? files : [];
652
- }
653
- /**
654
- * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
655
- * being sent.
656
- *
657
- * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
658
- * own enumerable properties. So a File left in a payload does not fail to send: it
659
- * arrives as an empty object, and the workflow rejects it against whatever its own
660
- * schema says the property should be. Measured in production as
661
- * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
662
- * about a type, on a form where the user had picked a perfectly good file.
663
- *
664
- * Exported beside {@link filesOf} because it is the same question asked at the
665
- * other end: that one decides which fields to UPLOAD, this one checks that none
666
- * were missed. Both hooks are the callers.
667
- */
668
- function fileFields(input) {
669
- if (!isRecord(input)) return [];
670
- return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
671
- }
672
- //#endregion
673
981
  //#region _repeat-until.ts
674
982
  /**
675
983
  * A bounded read, re-armed from the SETTLED read — the loop both workflow
@@ -1076,56 +1384,6 @@ function useWorkflows(opts = {}) {
1076
1384
  return state;
1077
1385
  }
1078
1386
  /**
1079
- * Replace every `File` in a submitted form with the id of a stored upload,
1080
- * reporting how far each one has got.
1081
- *
1082
- * Sequential rather than `Promise.all`: these are large bodies, and a form with
1083
- * two 200 MB recordings should send them one after another rather than compete
1084
- * for the same connection. That is also what makes a single bar honest — one
1085
- * file is in flight at a time, and `index`/`count` say which.
1086
- *
1087
- * Anything that is not a `File` (or an array of them) passes through untouched,
1088
- * so this is invisible to every form that has none — including one whose values
1089
- * are not an object at all, which `submit` accepts.
1090
- */
1091
- async function uploadFiles(api, input, report, parallel) {
1092
- if (!isRecord(input)) return input;
1093
- const entries = Object.entries(input);
1094
- const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
1095
- let index = 0;
1096
- const store = async (file) => {
1097
- index += 1;
1098
- const position = {
1099
- name: file.name,
1100
- index,
1101
- count
1102
- };
1103
- return (await api.upload(file, {
1104
- onProgress: (progress) => report({
1105
- ...position,
1106
- ...progress
1107
- }),
1108
- ...omitUndefined({ parallel })
1109
- })).id;
1110
- };
1111
- const out = {};
1112
- for (const [name, value] of entries) {
1113
- if (value instanceof File) {
1114
- out[name] = await store(value);
1115
- continue;
1116
- }
1117
- const chosen = filesOf(value);
1118
- if (chosen.length === 0) {
1119
- out[name] = value;
1120
- continue;
1121
- }
1122
- const ids = [];
1123
- for (const file of chosen) ids.push(await store(file));
1124
- out[name] = ids;
1125
- }
1126
- return out;
1127
- }
1128
- /**
1129
1387
  * Start a workflow from a form, and follow the run it creates.
1130
1388
  *
1131
1389
  * @typeParam R - The workflow's output type, which is what makes
@@ -1156,6 +1414,7 @@ function useWorkflowSubmit(workflow, opts = {}) {
1156
1414
  const [starting, setStarting] = useState(false);
1157
1415
  const [startError, setStartError] = useState(void 0);
1158
1416
  const [upload, setUpload] = useState(void 0);
1417
+ const session = useRef(void 0);
1159
1418
  const getClient = useWorkflowApiRef(api);
1160
1419
  const tracked = useWorkflowRun(runId, omitUndefined({
1161
1420
  api,
@@ -1167,18 +1426,24 @@ function useWorkflowSubmit(workflow, opts = {}) {
1167
1426
  setStarting(true);
1168
1427
  setStartError(void 0);
1169
1428
  setRunId(void 0);
1429
+ session.current?.gate.cancel();
1430
+ const current = createUploadSession();
1431
+ session.current = current;
1170
1432
  try {
1171
1433
  const options = omitUndefined({ key });
1172
- const started = await uploadFiles(client, input, setUpload, parallel);
1434
+ const started = await uploadFiles(client, input, setUpload, parallel, current);
1173
1435
  setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
1174
1436
  ...options,
1175
1437
  wait
1176
1438
  })).runId);
1177
1439
  } catch (err) {
1178
- setStartError(errorMessage(err));
1440
+ if (!current.gate.cancelled) setStartError(errorMessage(err));
1179
1441
  } finally {
1180
- setStarting(false);
1181
- setUpload(void 0);
1442
+ if (session.current === current) {
1443
+ session.current = void 0;
1444
+ setStarting(false);
1445
+ setUpload(void 0);
1446
+ }
1182
1447
  }
1183
1448
  }, [
1184
1449
  workflow,
@@ -1188,10 +1453,26 @@ function useWorkflowSubmit(workflow, opts = {}) {
1188
1453
  getClient
1189
1454
  ]),
1190
1455
  reset: useCallback(() => {
1456
+ session.current?.gate.cancel();
1457
+ session.current = void 0;
1191
1458
  setRunId(void 0);
1192
1459
  setStartError(void 0);
1193
1460
  setUpload(void 0);
1194
1461
  }, []),
1462
+ pauseUpload: useCallback(() => {
1463
+ session.current?.gate.pause();
1464
+ setUpload((current) => current ? {
1465
+ ...current,
1466
+ paused: true
1467
+ } : current);
1468
+ }, []),
1469
+ resumeUpload: useCallback(() => {
1470
+ session.current?.gate.resume();
1471
+ setUpload((current) => current ? {
1472
+ ...current,
1473
+ paused: false
1474
+ } : current);
1475
+ }, []),
1195
1476
  run: tracked.run,
1196
1477
  pending: starting || tracked.polling,
1197
1478
  upload,
@@ -1234,8 +1515,9 @@ function useWorkflowSubmit(workflow, opts = {}) {
1234
1515
  * @public
1235
1516
  */
1236
1517
  function WorkflowFields({ workflow }) {
1237
- const { workflows } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1518
+ const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1238
1519
  const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
1520
+ useDeclareFieldsPending(loading);
1239
1521
  const schema = asObjectSchema(summary?.inputSchema);
1240
1522
  if (!schema?.properties) return null;
1241
1523
  const required = new Set(schema.required ?? []);
@@ -1849,18 +2131,33 @@ function useWorkflowRuns(workflow, opts = {}) {
1849
2131
  * - **Reporting the bytes.** The same `UploadStatus` `useWorkflowSubmit` reports,
1850
2132
  * so `<UploadProgressBar>` renders either without knowing which hook it came from.
1851
2133
  *
1852
- * ## A failed upload is RESUMED once, and only then cancels the run
2134
+ * ## A failed upload is RESUMED, and only a spent budget cancels the run
1853
2135
  *
1854
2136
  * An upload that dies stays in the store, incomplete, and `complete` never becomes
1855
2137
  * true — so a run left behind polls until its own abandonment bound and then fails,
1856
2138
  * minutes after the page has already reported the error.
1857
2139
  *
1858
2140
  * That used to be the whole story, and it threw away a run and a file together for
1859
- * what is usually one dropped connection near the end. So a failure gets one more
1860
- * attempt with `resume: true`, which sends only the windows the store does not
1861
- * already have (`UploadInfo.ranges`) — the run is still waiting on the same id, so
1862
- * a resume that succeeds is invisible to it. Cancelling is what happens when THAT
1863
- * fails too, and it is the honest end to a submission that did not happen.
2141
+ * what is usually one dropped connection near the end. **The resume lives in the
2142
+ * SDK now** (`aai/sdk/_upload-resume.ts`): a round that fails for a reason that
2143
+ * looks like an outage is re-entered with `resume: true`, sending only the windows
2144
+ * the store does not already have, on a budget sized to outlast a redeploy. The run
2145
+ * is still waiting on the same id, so a resume that succeeds is invisible to it.
2146
+ *
2147
+ * This hook used to hand-roll one such retry and no longer does — one resume with
2148
+ * no wait in front of it covers a dropped connection and cannot cover the case that
2149
+ * actually strands people, which is the agent restarting underneath the upload.
2150
+ * Cancelling the run is what happens when the whole budget is spent, and it is the
2151
+ * honest end to a submission that did not happen.
2152
+ *
2153
+ * ## Pausing is the same mechanism, asked for
2154
+ *
2155
+ * `pauseUpload()` aborts the bytes in flight and holds the uploader;
2156
+ * `resumeUpload()` sends what is missing. The store cannot tell that from an
2157
+ * outage, because there is nothing to tell apart — see `_upload-session.ts`. The
2158
+ * RUN is untouched either way: it goes on polling the id it was started with, and
2159
+ * `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
2160
+ * pause has become an abandonment.
1864
2161
  */
1865
2162
  /**
1866
2163
  * Start a workflow run and stream a file into it while it works.
@@ -1893,6 +2190,7 @@ function useWorkflowStream(workflow, opts = {}) {
1893
2190
  const [starting, setStarting] = useState(false);
1894
2191
  const [startError, setStartError] = useState(void 0);
1895
2192
  const [upload, setUpload] = useState(void 0);
2193
+ const gateRef = useRef(void 0);
1896
2194
  const getClient = useWorkflowApiRef(api);
1897
2195
  const tracked = useWorkflowRun(runId, omitUndefined({
1898
2196
  api,
@@ -1904,42 +2202,41 @@ function useWorkflowStream(workflow, opts = {}) {
1904
2202
  setStarting(true);
1905
2203
  setStartError(void 0);
1906
2204
  setRunId(void 0);
2205
+ gateRef.current?.cancel();
2206
+ const gate = createUploadGate();
2207
+ gateRef.current = gate;
1907
2208
  let started;
1908
2209
  try {
1909
- const field = await uploadField(client, workflow);
1910
- const chosen = field === void 0 ? void 0 : fileAt(input, field);
1911
2210
  const id = randomUploadId();
1912
- const payload = chosen && field ? {
1913
- ...input,
1914
- [field]: id
1915
- } : input;
1916
- assertSendable(workflow, payload, field);
1917
- started = await client.start(workflow, payload, omitUndefined({ key }));
2211
+ const begun = await beginRun({
2212
+ client,
2213
+ workflow,
2214
+ input,
2215
+ id,
2216
+ ...omitUndefined({ key })
2217
+ });
2218
+ started = begun.runId;
2219
+ const chosen = begun.file;
1918
2220
  setRunId(started);
1919
2221
  if (!chosen) return;
1920
- const send = async (resume) => {
1921
- await client.uploadStream(id, chosen, {
1922
- name: chosen.name,
1923
- onProgress: (progress) => setUpload({
1924
- ...progress,
1925
- name: chosen.name,
1926
- index: 1,
1927
- count: 1
1928
- }),
1929
- ...omitUndefined({
1930
- parallel,
1931
- resume: resume ? true : void 0
1932
- })
1933
- });
1934
- };
1935
- await send(false).catch(async () => await send(true));
2222
+ await streamFile({
2223
+ client,
2224
+ gate,
2225
+ id,
2226
+ file: chosen,
2227
+ parallel,
2228
+ report: setUpload
2229
+ });
1936
2230
  await client.wake(started).catch(() => void 0);
1937
2231
  } catch (err) {
1938
- setStartError(errorMessage(err));
2232
+ if (!gate.cancelled) setStartError(errorMessage(err));
1939
2233
  if (started) await client.cancel(started).catch(() => void 0);
1940
2234
  } finally {
1941
- setStarting(false);
1942
- setUpload(void 0);
2235
+ if (gateRef.current === gate) {
2236
+ gateRef.current = void 0;
2237
+ setStarting(false);
2238
+ setUpload(void 0);
2239
+ }
1943
2240
  }
1944
2241
  }, [
1945
2242
  workflow,
@@ -1948,10 +2245,26 @@ function useWorkflowStream(workflow, opts = {}) {
1948
2245
  getClient
1949
2246
  ]),
1950
2247
  reset: useCallback(() => {
2248
+ gateRef.current?.cancel();
2249
+ gateRef.current = void 0;
1951
2250
  setRunId(void 0);
1952
2251
  setStartError(void 0);
1953
2252
  setUpload(void 0);
1954
2253
  }, []),
2254
+ pauseUpload: useCallback(() => {
2255
+ gateRef.current?.pause();
2256
+ setUpload((current) => current ? {
2257
+ ...current,
2258
+ paused: true
2259
+ } : current);
2260
+ }, []),
2261
+ resumeUpload: useCallback(() => {
2262
+ gateRef.current?.resume();
2263
+ setUpload((current) => current ? {
2264
+ ...current,
2265
+ paused: false
2266
+ } : current);
2267
+ }, []),
1955
2268
  run: tracked.run,
1956
2269
  pending: starting || tracked.polling,
1957
2270
  upload,
@@ -1959,6 +2272,56 @@ function useWorkflowStream(workflow, opts = {}) {
1959
2272
  };
1960
2273
  }
1961
2274
  /**
2275
+ * Read the declaration, substitute the id, and start the run.
2276
+ *
2277
+ * Everything that has to happen BEFORE a byte moves, which is the inversion this
2278
+ * hook exists for. One small `list()` per submit, deliberately: holding the
2279
+ * listing in state would make a submit before it landed a race, and the failure
2280
+ * mode of that race is a `File` reaching a run input.
2281
+ */
2282
+ async function beginRun(opts) {
2283
+ const { client, workflow, input, id } = opts;
2284
+ const field = await uploadField(client, workflow);
2285
+ const chosen = field === void 0 ? void 0 : fileAt(input, field);
2286
+ const payload = chosen && field ? {
2287
+ ...input,
2288
+ [field]: id
2289
+ } : input;
2290
+ assertSendable(workflow, payload, field);
2291
+ return {
2292
+ runId: await client.start(workflow, payload, omitUndefined({ key: opts.key })),
2293
+ file: chosen
2294
+ };
2295
+ }
2296
+ /**
2297
+ * Send the file, waiting out however many pauses the person takes.
2298
+ *
2299
+ * Its own function rather than a block inside `submit` because `submit` is
2300
+ * already carrying the ORDER this hook exists for — read the declaration, mint
2301
+ * the id, start the run, then the bytes, then the wake — and the sending is the
2302
+ * one step of that list with a loop in it.
2303
+ */
2304
+ async function streamFile(opts) {
2305
+ const { client, gate, id, file, parallel, report } = opts;
2306
+ await sendThroughGate(gate, async (resume) => {
2307
+ await client.uploadStream(id, file, {
2308
+ name: file.name,
2309
+ signal: gate.signal,
2310
+ onProgress: (progress) => report({
2311
+ ...progress,
2312
+ name: file.name,
2313
+ index: 1,
2314
+ count: 1,
2315
+ paused: gate.paused
2316
+ }),
2317
+ ...omitUndefined({
2318
+ parallel,
2319
+ resume: resume ? true : void 0
2320
+ })
2321
+ });
2322
+ });
2323
+ }
2324
+ /**
1962
2325
  * Refuse a payload carrying a `File`, before a run is started over it.
1963
2326
  *
1964
2327
  * A File cannot be SENT: a run input is JSON and `JSON.stringify(new File(…))` is
@@ -1985,10 +2348,6 @@ function assertSendable(workflow, payload, field) {
1985
2348
  const declares = field === void 0 ? "" : ` (it declares "${field}")`;
1986
2349
  throw new Error(`Cannot start "${workflow}": ${unsendable.join(", ")} ${carries} the workflow does not declare as an upload${declares}. Add the property to \`workflow({ uploads: [...] })\`, or submit an upload id.`);
1987
2350
  }
1988
- /** A fresh upload id: a capability, so it is random rather than derived. */
1989
- function randomUploadId() {
1990
- return crypto.randomUUID().replaceAll("-", "");
1991
- }
1992
2351
  /**
1993
2352
  * Which input property this workflow says carries an upload id.
1994
2353
  *