@alexkroman1/aai-ui 13.2.0 → 13.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/{_colors-j8XMToi9.js → _colors-CZ6OlPbL.js} +1 -1
  2. package/dist/{_module-url-C_4gRVL0.js → _module-url-C13kAJ87.js} +1 -1
  3. package/dist/_recover-run.d.ts +11 -5
  4. package/dist/{_utils-B6498_bm.js → _utils-CyzjK0gW.js} +1 -1
  5. package/dist/{aai-logo-CXZGPSIY.js → aai-logo-CFlomZlS.js} +1 -1
  6. package/dist/audio.js +1 -1
  7. package/dist/{chat-view-DDTtrh7N.js → chat-view-Bv5VFJIE.js} +6 -6
  8. package/dist/{client-config-BT_kWID5.js → client-config-DD820zHn.js} +1 -1
  9. package/dist/client-dir.js +1 -1
  10. package/dist/components/button.js +2 -2
  11. package/dist/components/chat-view.js +1 -1
  12. package/dist/components/controls.js +2 -2
  13. package/dist/components/message-list.js +1 -1
  14. package/dist/components/sidebar-layout.js +1 -1
  15. package/dist/components/start-screen.js +4 -4
  16. package/dist/components/tool-call-block.js +1 -1
  17. package/dist/context.js +1 -1
  18. package/dist/default-client.d.ts +1 -1
  19. package/dist/define-client.js +6 -6
  20. package/dist/{eyebrow-C6ZFuiz6.js → eyebrow-UfmSz9yy.js} +1 -1
  21. package/dist/hooks.js +2 -2
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.js +1168 -1114
  24. package/dist/internal.js +5 -5
  25. package/dist/{message-list-BJYyuIcR.js → message-list-C0pL7x41.js} +7 -7
  26. package/dist/{session-core-DxBYsfHA.js → session-core-C9elBIdu.js} +11 -11
  27. package/dist/session-core.js +1 -1
  28. package/dist/{tool-call-block-tcPQAkcP.js → tool-call-block-Bunc6rCw.js} +6 -6
  29. package/dist/{tool-config-context-DzAofqi_.js → tool-config-context-Bh8p3DtG.js} +1 -1
  30. package/dist/types.js +1 -1
  31. package/dist/{url-chips-YqhCjWfQ.js → url-chips-C2u7QPv8.js} +3 -3
  32. package/dist/use-run-key.d.ts +44 -10
  33. package/dist/{use-user-transcript-C14qWFu2.js → use-user-transcript-DFTSEuZN.js} +1 -1
  34. package/dist/use-workflow-form.d.ts +31 -90
  35. package/dist/{use-workflow-progress-Cu0SxMyg.js → use-workflow-run-CXGEcM0l.js} +256 -256
  36. package/dist/use-workflow-stream.d.ts +4 -1
  37. package/dist/use-workflows.d.ts +77 -0
  38. package/dist/worklets/capture-processor.js +2 -2
  39. package/dist/worklets/playback-processor.js +2 -2
  40. package/package.json +6 -6
  41. package/dist/tsdown.config.d.ts +0 -2
package/dist/index.js CHANGED
@@ -1,17 +1,17 @@
1
- import { n as fetchClientConfig } from "./client-config-BT_kWID5.js";
2
- import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-BJYyuIcR.js";
1
+ import { n as fetchClientConfig } from "./client-config-DD820zHn.js";
2
+ import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-C0pL7x41.js";
3
3
  import { ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
4
- import { r as inkTint } from "./_colors-j8XMToi9.js";
4
+ import { r as inkTint } from "./_colors-CZ6OlPbL.js";
5
5
  import { Button } from "./components/button.js";
6
- import { n as ConsoleShell, t as ChatView } from "./chat-view-DDTtrh7N.js";
7
- import { n as setPageTitle } from "./_utils-B6498_bm.js";
6
+ import { n as ConsoleShell, t as ChatView } from "./chat-view-Bv5VFJIE.js";
7
+ import { n as setPageTitle } from "./_utils-CyzjK0gW.js";
8
8
  import { Controls } from "./components/controls.js";
9
- import { n as useUserTranscript } from "./use-user-transcript-C14qWFu2.js";
10
- import { n as ToolCallRow } from "./tool-call-block-tcPQAkcP.js";
9
+ import { n as useUserTranscript } from "./use-user-transcript-DFTSEuZN.js";
10
+ import { n as ToolCallRow } from "./tool-call-block-Bunc6rCw.js";
11
11
  import { SidebarLayout } from "./components/sidebar-layout.js";
12
12
  import { StartScreen } from "./components/start-screen.js";
13
- import { a as useWorkflowRun, c as isTerminal, n as useWorkflowProgress, o as useWorkflowApiRef, s as createWorkflowApi } from "./use-workflow-progress-Cu0SxMyg.js";
14
- import { t as createSessionCore } from "./session-core-DxBYsfHA.js";
13
+ import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CXGEcM0l.js";
14
+ import { t as createSessionCore } from "./session-core-C9elBIdu.js";
15
15
  import { client, mountRoot, resolveContainer } from "./define-client.js";
16
16
  import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
17
17
  import clsx from "clsx";
@@ -20,7 +20,7 @@ import { createContext, createElement, useCallback, useContext, useEffect, useId
20
20
  import { errorMessage } from "@alexkroman1/aai";
21
21
  import { formatBytes, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
22
22
  import { createEpoch } from "@alexkroman1/aai/internal";
23
- //#region components/_form-readiness.ts
23
+ //#region src/components/_form-readiness.ts
24
24
  /**
25
25
  * Whether a form's own fields exist yet.
26
26
  *
@@ -106,7 +106,7 @@ function useDeclareFieldsPending(pending) {
106
106
  ]);
107
107
  }
108
108
  //#endregion
109
- //#region components/_form-values.ts
109
+ //#region src/components/_form-values.ts
110
110
  /**
111
111
  * Read one `<form>`'s named controls into a plain object.
112
112
  *
@@ -201,7 +201,7 @@ function dataUrl(file) {
201
201
  });
202
202
  }
203
203
  //#endregion
204
- //#region components/form-fields.tsx
204
+ //#region src/components/form-fields.tsx
205
205
  /** @jsxImportSource react */
206
206
  /**
207
207
  * The controls a form is made of — the shell, the six fields, and the styling
@@ -512,7 +512,7 @@ function FileField({ name, label, hint, className, read = "none", upload = false
512
512
  });
513
513
  }
514
514
  //#endregion
515
- //#region components/form.tsx
515
+ //#region src/components/form.tsx
516
516
  /** @jsxImportSource react */
517
517
  /**
518
518
  * Simple forms, for the pages that are not conversations.
@@ -654,7 +654,7 @@ function SubmitButton({ children, pending = false, pendingLabel = "Working…",
654
654
  });
655
655
  }
656
656
  //#endregion
657
- //#region components/upload-progress.tsx
657
+ //#region src/components/upload-progress.tsx
658
658
  /** @jsxImportSource react */
659
659
  /**
660
660
  * The track behind the fill, as a tint of the theme's own ink.
@@ -766,1287 +766,1341 @@ function UploadProgressBar({ upload, onPause, onResume, className }) {
766
766
  });
767
767
  }
768
768
  //#endregion
769
- //#region _recover-run.ts
769
+ //#region src/use-workflows.ts
770
770
  /**
771
- * Finding a run again when the page has lost its id.
772
- *
773
- * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
774
- * which was only half true of the hooks above it: the run id lived in plain
775
- * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
776
- * live run with nothing anywhere able to name it. The run really did continue;
777
- * the person really could not get back to it.
778
- *
779
- * `StartOptions.key` is the handle that survives that, and it always was — a
780
- * caller's own name for a run, indexed by the agent, read back with
781
- * `find(workflow, key)`. What was missing is the two lines that ASK. This is
782
- * them, plus the four decisions they turn out to carry.
783
- *
784
- * ## It is a MOUNT-time act, not "whenever there is no run"
785
- *
786
- * The tempting spelling is "if we hold no run id, look one up", and it breaks
787
- * `reset()`: a form put back to its initial state holds no run id, so the next
788
- * pass would re-adopt the very run the person had just dismissed — a Clear
789
- * button that clears nothing. So the lookup runs once per mount (and again only
790
- * if the KEY changes, which is a different person's run), and every later
791
- * absence of a run id is taken at face value.
792
- *
793
- * ## The lookup NEVER wins a race against a submit
771
+ * The agent's declared workflows — what a form is rendered FROM.
794
772
  *
795
- * A person who reloads and immediately submits has started the run they want,
796
- * and an answer that was already in flight names an older one. The caller
797
- * therefore adopts through `current ?? found`: the recovered id fills an empty
798
- * slot and never replaces a full one.
773
+ * Split out of `use-workflow-form.ts` at the 500-line cap, along the seam that
774
+ * file's own doc already drew: it held "the two hooks a FORM needs", and only
775
+ * one of them is about a RUN. This is the other one, and it shares nothing with
776
+ * its former neighbours but the client ref every hook here uses — no state, no
777
+ * run id, no upload.
778
+ */
779
+ /**
780
+ * Read the agent's declared workflows.
799
781
  *
800
- * ## A failed lookup is REPORTED
782
+ * What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
783
+ * Schema of that workflow's input, converted server-side precisely so a browser
784
+ * can read it.
801
785
  *
802
- * The alternative is a page that quietly shows an empty form to somebody whose
803
- * run is live, who then starts a second one — the duplicated work the key
804
- * exists to prevent, and on a workflow app that is real money. A person who has
805
- * never run anything pays a banner they can ignore. Same trade as
806
- * `useWorkflows`, for the same reason: an empty answer here is a confident
807
- * false statement.
786
+ * The failure is reported rather than swallowed, because the alternative is an
787
+ * empty list — which renders as a form with no fields and reads as "this agent
788
+ * declares no workflows" about an agent that was merely unreachable.
808
789
  *
809
- * ## It is OPT-IN
790
+ * @example
791
+ * ```tsx
792
+ * import { useWorkflows } from "@alexkroman1/aai-ui";
810
793
  *
811
- * A `key` on its own still means only "record this with the run", which is what
812
- * a voice agent's `ctx.workflows.start({ key })` means and what a page passing
813
- * an account id may well want. Adopting a run is a decision about the PAGE, so
814
- * it is `recover: true` and the two together read as what they do.
815
- */
816
- /**
817
- * Look up the newest run for a key, once, as the component mounts.
794
+ * // A page rendering its own chrome from the listing — a picker, say. A form
795
+ * // for ONE workflow wants `<WorkflowFields workflow="name" />` instead,
796
+ * // which does this lookup itself.
797
+ * function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) {
798
+ * const { workflows, loading, error } = useWorkflows();
799
+ * if (loading) return <p>Loading…</p>;
800
+ * if (error !== undefined) return <p role="alert">{error}</p>;
801
+ * return (
802
+ * <ul>
803
+ * {workflows.map((summary) => (
804
+ * <li key={summary.name}>
805
+ * <button type="button" onClick={() => onPick(summary.name)}>
806
+ * {summary.description ?? summary.name}
807
+ * </button>
808
+ * </li>
809
+ * ))}
810
+ * </ul>
811
+ * );
812
+ * }
813
+ * ```
818
814
  *
819
- * @param opts - See {@link RecoverRunOptions}.
820
- * @returns Whether the lookup is still out. A caller folds it into its own
821
- * `pending`, because a form offering Submit while a live run is arriving is a
822
- * form inviting a second one.
815
+ * @param opts - See {@link UseWorkflowsOptions}.
816
+ * @returns The listing, its loading flag and its failure — see
817
+ * {@link UseWorkflowsResult}.
823
818
  *
824
- * @internal
819
+ * @public
825
820
  */
826
- function useRecoveredRun(opts) {
827
- const { workflow, key, enabled, getClient } = opts;
828
- const [recovering, setRecovering] = useState(enabled && key !== void 0);
829
- const handlers = useRef(opts);
830
- handlers.current = opts;
821
+ function useWorkflows(opts = {}) {
822
+ const { api, skip = false } = opts;
823
+ const [state, setState] = useState({
824
+ workflows: [],
825
+ loading: !skip,
826
+ error: void 0
827
+ });
828
+ const getClient = useWorkflowApiRef(api);
831
829
  useEffect(() => {
832
- if (!enabled || key === void 0) return;
830
+ if (skip) return;
833
831
  let cancelled = false;
834
- setRecovering(true);
835
- getClient().find(workflow, key, { limit: 1 }).then((found) => {
836
- if (cancelled) return;
837
- const newest = found[0];
838
- if (newest !== void 0) handlers.current.onFound(newest.runId);
839
- setRecovering(false);
832
+ getClient().list().then((workflows) => {
833
+ if (!cancelled) setState({
834
+ workflows,
835
+ loading: false,
836
+ error: void 0
837
+ });
840
838
  }).catch((err) => {
841
839
  if (cancelled) return;
842
- handlers.current.onError(errorMessage(err));
843
- setRecovering(false);
840
+ setState({
841
+ workflows: [],
842
+ loading: false,
843
+ error: errorMessage(err)
844
+ });
844
845
  });
845
846
  return () => {
846
847
  cancelled = true;
847
848
  };
848
- }, [
849
- enabled,
850
- key,
851
- workflow,
852
- getClient
853
- ]);
854
- return recovering;
849
+ }, [skip, getClient]);
850
+ return state;
855
851
  }
856
852
  //#endregion
857
- //#region _run-controls.ts
853
+ //#region src/components/workflow-fields.tsx
854
+ /** @jsxImportSource react */
858
855
  /**
859
- * The two things a page does TO a run it started, bound to the run it has.
860
- *
861
- * `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
862
- * handed it back, so a page that wanted "send it now" or "stop" had to hold an
863
- * `api` of its own purely to write `api.wake(runId)` — which is the whole reason
864
- * the two raw-primitive template pages keep a client at module scope. That is a
865
- * page carrying the transport to make up for a hook withholding its own state.
856
+ * A form built from a workflow's declared input schema.
866
857
  *
867
- * Both calls answer rather than fail when there is nothing to act on — `0`
868
- * sleeps ended, `false` this call did not end it — which is the SDK's own
869
- * contract for them (two tabs pressing Stop is ordinary), and it is what lets
870
- * the no-run case be the same answer rather than a special one a caller has to
871
- * branch on.
872
- */
873
- /**
874
- * Bind `wake` and `cancel` to whatever run the hook is currently following.
858
+ * `GET workflows` reports each workflow's `inputSchema` as JSON Schema — the
859
+ * zod schema an author wrote in `agent.ts`, converted at listing time precisely
860
+ * so a browser can read it. This is what reads it: one `<WorkflowFields>` and a
861
+ * workflow's form matches its schema by construction, so adding a field to the
862
+ * schema adds it to the page and nothing can drift.
875
863
  *
876
- * @param runId - The live run, or `undefined` before one exists.
877
- * @param getClient - The stable getter from `useWorkflowApiRef`.
878
- * @returns Two callbacks, stable while `runId` is.
864
+ * ## It covers SCALARS, and says so rather than guessing
879
865
  *
880
- * @internal
866
+ * A string, number, integer, boolean or enum has one obvious control each. A
867
+ * nested object or an array does not — every choice (a JSON textarea, a repeater,
868
+ * a comma-separated string) is a guess about what the author meant, and a guess
869
+ * that produces a value the schema then rejects is worse than no field at all.
870
+ * So those are SKIPPED, and the fields for them are written by hand: every field
871
+ * in this package is a plain named control, so a hand-written one composes with
872
+ * a generated one inside the same {@link Form}.
881
873
  */
882
- function useRunControls(runId, getClient) {
883
- return {
884
- wake: useCallback(async () => {
885
- if (runId === void 0) return 0;
886
- return await getClient().wake(runId);
887
- }, [runId, getClient]),
888
- cancel: useCallback(async () => {
889
- if (runId === void 0) return false;
890
- return await getClient().cancel(runId);
891
- }, [runId, getClient])
892
- };
893
- }
894
- //#endregion
895
- //#region _upload-recall.ts
896
874
  /**
897
- * Where an upload's ID survives a page RELOAD.
898
- *
899
- * A streamed upload is resumable because its id outlives the attempt that began
900
- * it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
901
- * pause a person can press. Both of them hold the id in MEMORY: the walk's
902
- * `UploadSession` lives in a `useRef`, so a reload was the one interruption the
903
- * mechanism could not survive. Everything else was already in place — the windows
904
- * were still in the store, the agent could still name them
905
- * (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
906
- * had thrown away the only name for them. So a person who refreshed at 90% of a
907
- * 200 MB recording sent the whole file again, which is the one interruption they
908
- * are most likely to cause on purpose.
909
- *
910
- * This is that name, written down. It is what tus-js-client's `urlStorage` and
911
- * Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
912
- * uses for a session id.
875
+ * Render one field per scalar property of a workflow's input schema.
913
876
  *
914
- * ## A FINGERPRINT, because a `File` has no name a page can address
877
+ * Pass the workflow's NAME and the schema is fetched here; pass a
878
+ * {@link WorkflowSummary} you already hold and nothing is fetched. The name form
879
+ * is the one a page usually wants — it is the same string the submit hook takes,
880
+ * and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
881
+ * folding that lookup's error into the form's) whose only product is this
882
+ * component's argument.
915
883
  *
916
- * A file from a picker carries no path and no handle, so the key is what
917
- * tus-js-client fingerprints on: size, last-modified, type and name. Two
918
- * different files agreeing on all four is the case this cannot tell apart — and
919
- * the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
920
- * what the id actually holds before sending a byte to it, so a wrong hit costs
921
- * one `GET` and a fresh id rather than a corrupted upload.
884
+ * Renders nothing when the workflow declared no schema — a workflow with no
885
+ * declared input takes anything, and a form for "anything" is not a form — and
886
+ * nothing while a named lookup is still in flight, so the hand-written fields
887
+ * beside it are not reordered when the schema lands.
922
888
  *
923
- * ## `sessionStorage`, deliberately
889
+ * @example
890
+ * ```tsx no-check
891
+ * import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
892
+ * from "@alexkroman1/aai-ui";
893
+ * import type { transcribe } from "./agent.ts";
924
894
  *
925
- * The same call `session-resume-store.ts` makes, for a reason that happens to be
926
- * stronger here: a reload and a same-tab navigation are exactly what this is for,
927
- * and an id from yesterday names an upload the agent's sweep has very likely
928
- * already collected. A tab is also the boundary the walk itself has — two tabs
929
- * uploading the same recording are two submissions.
895
+ * function StartRun() {
896
+ * const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe");
897
+ * return (
898
+ * <Form onSubmit={submitForm} error={error}>
899
+ * <WorkflowFields workflow="transcribe" />
900
+ * <SubmitButton pending={pending}>Transcribe</SubmitButton>
901
+ * </Form>
902
+ * );
903
+ * }
904
+ * ```
930
905
  *
931
- * Every access is guarded. Storage throws outright in Safari private mode and
932
- * under a blocking policy, and an upload that cannot be REMEMBERED must degrade
933
- * to the upload we would have done anyway rather than failing to start.
934
- */
935
- const PREFIX$1 = "aai:upload:";
936
- /**
937
- * How many ids one form keeps.
906
+ * @param props - Field-set props.
938
907
  *
939
- * A cap rather than an expiry, because `sessionStorage` already expires with the
940
- * tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
941
- * submits a hundred files: the oldest go first, and the id most likely to be
942
- * worth resuming is the one written last.
908
+ * @public
943
909
  */
944
- const MAX_REMEMBERED = 32;
945
- /** One form's slot in storage. */
946
- function keyFor(scope) {
947
- return `${PREFIX$1}${scope}`;
910
+ function WorkflowFields({ workflow }) {
911
+ const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
912
+ const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
913
+ useDeclareFieldsPending(loading);
914
+ const schema = asObjectSchema(summary?.inputSchema);
915
+ if (!schema?.properties) return null;
916
+ const required = new Set(schema.required ?? []);
917
+ const uploads = new Set(summary?.uploads ?? []);
918
+ return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
919
+ name,
920
+ property,
921
+ required: required.has(name),
922
+ upload: uploads.has(name)
923
+ }, name)) });
948
924
  }
949
- /**
950
- * What names this file across a reload.
951
- *
952
- * The four fields a browser gives a picked file that do not change between loads.
953
- * `name` last because it is the one a person can read in a debugger.
954
- */
955
- function fingerprint(file) {
956
- return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
957
- }
958
- /** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
959
- function read(scope) {
960
- try {
961
- const raw = globalThis.sessionStorage?.getItem(keyFor(scope));
962
- if (raw === null || raw === void 0) return {};
963
- const parsed = JSON.parse(raw);
964
- return isRecord(parsed) ? parsed : {};
965
- } catch {
966
- return {};
925
+ /** One property's control, or nothing when its type has no obvious one. */
926
+ function SchemaField({ name, property, required, upload = false }) {
927
+ const label = humanize(name);
928
+ const hint = property.description === void 0 ? {} : { hint: property.description };
929
+ const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
930
+ if (upload) return /* @__PURE__ */ jsx(FileField, {
931
+ name,
932
+ label,
933
+ required,
934
+ upload: true,
935
+ ...hint
936
+ });
937
+ if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
938
+ name,
939
+ label,
940
+ required,
941
+ options: property.enum.map((value) => String(value)),
942
+ ...hint,
943
+ ...defaults
944
+ });
945
+ const type = typeOf(property);
946
+ switch (type) {
947
+ case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
948
+ name,
949
+ label,
950
+ defaultChecked: property.default === true,
951
+ ...hint
952
+ });
953
+ case "number":
954
+ case "integer": return /* @__PURE__ */ jsx(NumberField, {
955
+ name,
956
+ label,
957
+ required,
958
+ step: type === "integer" ? 1 : "any",
959
+ ...hint,
960
+ ...defaults
961
+ });
962
+ case "string": return /* @__PURE__ */ jsx(TextField, {
963
+ name,
964
+ label,
965
+ required,
966
+ ...hint,
967
+ ...defaults
968
+ });
969
+ default: return null;
967
970
  }
968
971
  }
969
- function write(scope, entries) {
970
- try {
971
- globalThis.sessionStorage?.setItem(keyFor(scope), JSON.stringify(entries));
972
- } catch {}
972
+ /** A property's type, taking the first non-null member of a union. */
973
+ function typeOf(property) {
974
+ const { type } = property;
975
+ if (typeof type === "string") return type;
976
+ return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
977
+ }
978
+ /** The listing's `unknown` schema as the object shape this reads, when it is one. */
979
+ function asObjectSchema(schema) {
980
+ return isRecord(schema) ? schema : void 0;
973
981
  }
974
982
  /**
975
- * The id this file was last being stored under in this tab, if any.
976
- *
977
- * A hit is a CANDIDATE and never a decision — see the module doc.
983
+ * A property name as a label — `recordingId` → `Recording id`.
978
984
  *
979
- * @internal
985
+ * A default, not a policy: a schema whose labels matter should carry a
986
+ * `.describe()`, and an author who wants exact control writes the field.
980
987
  */
981
- function recallUploadId(scope, file) {
982
- const found = read(scope)[fingerprint(file)];
983
- return typeof found === "string" ? found : void 0;
988
+ function humanize(name) {
989
+ const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase();
990
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
984
991
  }
992
+ //#endregion
993
+ //#region src/components/workflow-progress.tsx
994
+ /** @jsxImportSource react */
985
995
  /**
986
- * Remember the id this file is being stored under.
996
+ * What a run has said so far, rendered.
987
997
  *
988
- * Called before the first byte leaves rather than after the last one lands: the
989
- * reload this exists for happens in between, and an id written at the end is an
990
- * id written for the one case that did not need it.
998
+ * The complement of a status line, and the reason both exist: a run is
999
+ * `running` for its whole life, so a one-round job and a ten-round one look
1000
+ * identical while they happen. These lines come from the run itself (`report()`
1001
+ * in a `"use step"` body), which is the only channel a workflow has before it
1002
+ * produces an output.
991
1003
  *
992
- * @internal
1004
+ * Three rules are baked in, and they are why this is a component rather than
1005
+ * three lines each page writes for itself — the two templates that had written
1006
+ * it had written all three, comments included:
1007
+ *
1008
+ * - **It renders nothing until there is something to render.** `supported` is
1009
+ * what keeps this from being an empty box forever on an agent deployed before
1010
+ * progress streams existed: "wrote nothing yet" and "serves no stream" are
1011
+ * indistinguishable from the chunk list alone.
1012
+ * - **The lines are TEXT, not elements.** They are append-only and two rounds
1013
+ * legitimately produce identical text, so there is no stable per-line key to
1014
+ * give React. Joining sidesteps the question instead of suppressing the lint
1015
+ * rule that asks it.
1016
+ * - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
1017
+ * or opening a finished run tomorrow — shows how it got there rather than an
1018
+ * empty box. That is `useWorkflowProgress`'s doing; this is what makes it
1019
+ * visible.
1020
+ *
1021
+ * @example
1022
+ * ```tsx
1023
+ * import { WorkflowProgress } from "@alexkroman1/aai-ui";
1024
+ *
1025
+ * function RunPanel({ runId }: { runId: string }) {
1026
+ * return <WorkflowProgress runId={runId} />;
1027
+ * }
1028
+ * ```
1029
+ *
1030
+ * @param props - Progress-log props.
1031
+ *
1032
+ * @public
993
1033
  */
994
- function rememberUploadId(scope, file, id) {
995
- const entries = read(scope);
996
- const key = fingerprint(file);
997
- delete entries[key];
998
- entries[key] = id;
999
- const keys = Object.keys(entries);
1000
- for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
1001
- write(scope, entries);
1034
+ function WorkflowProgress({ runId, api, className, placeholder, lines }) {
1035
+ const { progress, streaming, supported } = useWorkflowProgress(runId, omitUndefined({ api }));
1036
+ const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
1037
+ if (!supported || shown.length === 0) return placeholder ?? null;
1038
+ return /* @__PURE__ */ jsxs("pre", {
1039
+ className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
1040
+ children: [shown.join("\n"), streaming && "\n…"]
1041
+ });
1002
1042
  }
1043
+ //#endregion
1044
+ //#region src/page.tsx
1045
+ /** @jsxImportSource react */
1003
1046
  /**
1004
- * Forget it: the agent holds nothing resumable under this id.
1047
+ * `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
1005
1048
  *
1006
- * The other half of the agent deciding. Without it a swept upload is re-read on
1007
- * every submission of the same file for the life of the tab, which is a round
1008
- * trip spent learning the same 404.
1049
+ * The twin of `client()` for an agent whose front door is a form rather than a
1050
+ * microphone (`workflowApp()`). It is a separate entry rather than
1051
+ * an option on `client()` because of what `client()` unavoidably does: it
1052
+ * constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
1053
+ * graph, and a microphone request. A flag would have to make all of that
1054
+ * conditional, and every session hook would then have to answer "what does this
1055
+ * mean with no session?" — so the honest split is two mounts. A page that wants
1056
+ * voice uses `client()`; a page that wants neither audio nor a socket uses this.
1009
1057
  *
1010
- * @internal
1058
+ * Authoring is otherwise identical — the file is still `client.tsx`, still
1059
+ * React, still Tailwind, still the same theme tokens — so a workflow app reads
1060
+ * like every other agent. What it reaches for instead of `useSession()` is
1061
+ * `createWorkflowApi()` / `useWorkflowRun()`.
1011
1062
  */
1012
- function forgetUploadId(scope, file) {
1013
- const entries = read(scope);
1014
- const key = fingerprint(file);
1015
- if (!(key in entries)) return;
1016
- delete entries[key];
1017
- write(scope, entries);
1018
- }
1019
- //#endregion
1020
- //#region _upload-session.ts
1021
- /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
1022
- function isAbortError(err) {
1023
- return err instanceof Error && err.name === "AbortError";
1024
- }
1025
- /** A fresh upload id: a capability, so it is random rather than derived. */
1026
- function randomUploadId() {
1027
- return crypto.randomUUID().replaceAll("-", "");
1028
- }
1029
1063
  /**
1030
- * Send one file, waiting out however many pauses the person takes.
1064
+ * Mount a page for an agent whose work happens in workflows.
1031
1065
  *
1032
- * The loop from the module doc, written once: both hooks need exactly this and a
1033
- * second copy of it is a second place for the abort/pause distinction to be got
1034
- * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
1035
- * false the first time, since a fresh id has nothing to resume and saying
1036
- * otherwise waives the refusal that makes a caller-chosen id safe.
1066
+ * There is deliberately no session, no microphone, and no socket: the component
1067
+ * talks to the agent over the workflow HTTP API
1068
+ * (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
1037
1069
  *
1038
- * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
1039
- * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
1040
- * how an abandoned submission unwinds without being reported as a failure.
1070
+ * @example
1071
+ * ```tsx
1072
+ * import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
1073
+ * import { useState } from "react";
1074
+ *
1075
+ * // Hoisted: a client built in render is a new object every render.
1076
+ * const api = createWorkflowApi();
1077
+ *
1078
+ * function App() {
1079
+ * const [runId, setRunId] = useState<string>();
1080
+ * const { run } = useWorkflowRun(runId, { api });
1081
+ * return (
1082
+ * <button
1083
+ * type="button"
1084
+ * onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
1085
+ * >
1086
+ * {run ? run.status : "Start"}
1087
+ * </button>
1088
+ * );
1089
+ * }
1090
+ *
1091
+ * page({ name: "Digest", component: App });
1092
+ * ```
1093
+ *
1094
+ * @throws If the target element is not found in the DOM.
1095
+ *
1096
+ * @public
1041
1097
  */
1042
- async function sendThroughGate(gate, send) {
1043
- let tried = false;
1044
- for (;;) {
1045
- await gate.settle();
1046
- if (gate.cancelled) throw new Error("Upload cancelled.");
1047
- const resume = tried;
1048
- tried = true;
1049
- try {
1050
- await send(resume);
1051
- return;
1052
- } catch (err) {
1053
- if (gate.cancelled || !isAbortError(err)) throw err;
1054
- }
1055
- }
1098
+ function page(config) {
1099
+ const container = resolveContainer(config.target);
1100
+ setPageTitle(config.name);
1101
+ return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
1056
1102
  }
1103
+ //#endregion
1104
+ //#region src/use-download-url.ts
1057
1105
  /**
1058
- * A gate, open.
1106
+ * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
1107
+ * `<img>` or `<a download>` will accept.
1059
1108
  *
1060
- * One per upload rather than one per hook: the id and the windows already stored
1061
- * belong to a file, so a gate that outlived its file would resume something else.
1062
- */
1063
- function createUploadGate() {
1064
- let controller = new AbortController();
1065
- let paused = false;
1066
- let cancelled = false;
1067
- let open;
1068
- let closed;
1069
- return {
1070
- get paused() {
1071
- return paused;
1072
- },
1073
- get cancelled() {
1074
- return cancelled;
1075
- },
1076
- get signal() {
1077
- return controller.signal;
1078
- },
1079
- pause() {
1080
- if (paused || cancelled) return;
1081
- paused = true;
1082
- const gate = Promise.withResolvers();
1083
- closed = gate.promise;
1084
- open = gate.resolve;
1085
- controller.abort();
1086
- },
1087
- resume() {
1088
- if (!paused || cancelled) return;
1089
- paused = false;
1090
- controller = new AbortController();
1091
- open?.();
1092
- open = void 0;
1093
- closed = void 0;
1094
- },
1095
- cancel() {
1096
- if (cancelled) return;
1097
- cancelled = true;
1098
- paused = false;
1099
- controller.abort();
1100
- open?.();
1101
- open = void 0;
1102
- closed = void 0;
1103
- },
1104
- async settle() {
1105
- if (closed) await closed;
1106
- }
1107
- };
1108
- }
1109
- //#endregion
1110
- //#region _workflow-files.ts
1111
- /**
1112
- * Which of a submitted form's values are FILES.
1109
+ * `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
1110
+ * same bearer every workflow route does, and neither `<audio src>` nor
1111
+ * `<a href>` can send one. So every page that plays back what a run WROTE ends
1112
+ * up at the same four lines — `download` → `createObjectURL` → state — and the
1113
+ * two that are really the point are the two the four lines are wrapped in:
1113
1114
  *
1114
- * Its own module because both submit hooks need the identical answer and then do
1115
- * two different things with it — `useWorkflowSubmit` stores each file and passes
1116
- * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
1117
- * A second copy of this predicate would be a form field that one hook treats as a
1118
- * file and the other does not, which is invisible until the run reads the wrong
1119
- * kind of string.
1120
- */
1121
- /**
1122
- * The files a submitted field carries, if that is what it carries.
1115
+ * - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
1116
+ * life of the DOCUMENT. Miss it and every completed run's audio stays resident
1117
+ * until the tab closes, which on a page people run all day is a leak measured
1118
+ * in the size of the files.
1119
+ * - **A `cancelled` flag.** A second run settling while the first download is
1120
+ * still in flight otherwise sets state from the stale one, and the page plays
1121
+ * the previous run's audio under the current run's transcript — a wrong answer
1122
+ * that looks like a right one.
1123
1123
  *
1124
- * An array counts only when it is files ALL the way through — a mixed array is
1125
- * some other field's value that happens to contain one, and turning half of it
1126
- * into ids would corrupt it silently.
1124
+ * Two templates had written this hook, identically, doc paragraph included, and
1125
+ * `aai-ui` exported no download helper at all. Both also faked `pending` by
1126
+ * checking `url === undefined && error === undefined`, which reads "idle" and
1127
+ * "downloading" as the same thing — so this reports it.
1127
1128
  */
1128
- function filesOf(value) {
1129
- if (value instanceof File) return [value];
1130
- if (!Array.isArray(value)) return [];
1131
- const files = value.filter((one) => one instanceof File);
1132
- return files.length > 0 && files.length === value.length ? files : [];
1133
- }
1129
+ /** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
1130
+ const IDLE = { pending: false };
1131
+ /** Bytes in flight. Shared for the same reason as {@link IDLE}. */
1132
+ const PENDING = { pending: true };
1134
1133
  /**
1135
- * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
1136
- * being sent.
1134
+ * Read an upload's bytes and hand back a URL a DOM element can use.
1137
1135
  *
1138
- * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
1139
- * own enumerable properties. So a File left in a payload does not fail to send: it
1140
- * arrives as an empty object, and the workflow rejects it against whatever its own
1141
- * schema says the property should be. Measured in production as
1142
- * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
1143
- * about a type, on a form where the user had picked a perfectly good file.
1136
+ * @param uploadId - The id a completed run reported, or `undefined` before one
1137
+ * exists — which is what a page passes straight through while it waits, and
1138
+ * reports as idle rather than pending.
1139
+ * @param opts - See {@link UseDownloadUrlOptions}.
1140
+ * @returns See {@link UseDownloadUrlResult}.
1144
1141
  *
1145
- * Exported beside {@link filesOf} because it is the same question asked at the
1146
- * other end: that one decides which fields to UPLOAD, this one checks that none
1147
- * were missed. Both hooks are the callers.
1148
- */
1149
- function fileFields(input) {
1150
- if (!isRecord(input)) return [];
1151
- return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
1152
- }
1153
- //#endregion
1154
- //#region _upload-files.ts
1155
- /**
1156
- * Turning a form's `File`s into stored upload ids, pauses and all.
1142
+ * @example
1143
+ * ```tsx no-check
1144
+ * import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
1145
+ * import type { spokenSummary } from "./agent.ts";
1157
1146
  *
1158
- * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
1159
- * real one: that module is the two HOOKS and the state between them, where this
1160
- * is the walk over a submitted input — which is the only part of it that knows
1161
- * what a `File` is, holds a loop, and survives being re-entered.
1147
+ * function Playback() {
1148
+ * const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
1149
+ * const output = run?.status === "completed" ? run.output : undefined;
1150
+ * const audio = useDownloadUrl(output?.audio);
1151
+ * if (audio.pending) return <p>Fetching audio…</p>;
1152
+ * if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
1153
+ * return audio.url === undefined ? null : (
1154
+ * <a href={audio.url} download="summary.mp3">
1155
+ * Download
1156
+ * </a>
1157
+ * );
1158
+ * }
1159
+ * ```
1162
1160
  *
1163
- * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
1164
- * one file rather than walking an input and shares only the gate underneath both
1165
- * (`_upload-session.ts`).
1161
+ * @public
1166
1162
  */
1167
- /** A fresh session for one submission of `workflow`. */
1168
- function createUploadSession(workflow) {
1169
- return {
1170
- scope: workflow,
1171
- ids: /* @__PURE__ */ new Map(),
1172
- stored: /* @__PURE__ */ new Map(),
1173
- tried: /* @__PURE__ */ new Set(),
1174
- gate: createUploadGate()
1175
- };
1163
+ function useDownloadUrl(uploadId, opts = {}) {
1164
+ const [state, setState] = useState(IDLE);
1165
+ const getClient = useWorkflowApiRef(opts.api);
1166
+ useEffect(() => {
1167
+ if (uploadId === void 0) {
1168
+ setState(IDLE);
1169
+ return;
1170
+ }
1171
+ let cancelled = false;
1172
+ let objectUrl;
1173
+ setState(PENDING);
1174
+ getClient().download(uploadId).then((blob) => {
1175
+ if (cancelled) return;
1176
+ objectUrl = URL.createObjectURL(blob);
1177
+ setState({
1178
+ url: objectUrl,
1179
+ pending: false
1180
+ });
1181
+ }).catch((err) => {
1182
+ if (!cancelled) setState({
1183
+ error: errorMessage(err),
1184
+ pending: false
1185
+ });
1186
+ });
1187
+ return () => {
1188
+ cancelled = true;
1189
+ if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
1190
+ };
1191
+ }, [uploadId, getClient]);
1192
+ return state;
1176
1193
  }
1194
+ //#endregion
1195
+ //#region src/use-run-key.ts
1177
1196
  /**
1178
- * The id to store this file under: the one a previous page load was using, or a
1179
- * fresh one.
1197
+ * The handle a page keeps on the runs it started, across a reload.
1180
1198
  *
1181
- * **The AGENT decides, never the fingerprint.** Two files can agree on every
1182
- * field `_upload-recall.ts` keys by, so the recalled id is a candidate that has to
1183
- * be checked before a byte is sent to it — and the check is cheap and exact,
1184
- * because `uploadInfo` is the same record a resume already reads.
1199
+ * `useWorkflowSubmit` is what makes a run survivable — the run id is that
1200
+ * hook's own state, so a refresh loses it while the run carries on — and the
1201
+ * `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
1202
+ * there is no per-user filtering behind `find`, so the key IS the scoping
1203
+ * mechanism. Choosing one is easy to get wrong in three separate ways, and six
1204
+ * shipped templates had each written the same twenty lines to get it right.
1205
+ * This is those lines.
1206
+ *
1207
+ * **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
1208
+ * so a page resumes its own run across a reload with nothing written at the
1209
+ * call site — six of six page templates passed `useRunKey()` and
1210
+ * `recover: true`, which is a default in the wrong place. The hook stays
1211
+ * PUBLIC for the page that wants to choose: an app with accounts passes the
1212
+ * ACCOUNT's own id instead, and a run then follows the person to a new device,
1213
+ * which is a promise only a login can keep; a page whose run outlives the tab
1214
+ * passes `useRunKey({ storage: "local" })`.
1185
1215
  *
1186
- * Three answers, and the third is why this is not just a storage lookup:
1216
+ * ## Three properties, and the rejected alternatives are why each one matters
1187
1217
  *
1188
- * - **Complete.** Every byte is in from a load that is gone, so there is nothing
1189
- * to send: the caller takes the id and starts the run. This is the refresh that
1190
- * costs one `GET` instead of a second 200 MB upload.
1191
- * - **Unfinished, with windows.** `UploadInfo.ranges` is what makes an
1192
- * upload resumable at all, so the id is reused and the attempt claims it.
1193
- * - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
1194
- * upload reporting NO windows — which is a partial single `PUT`, and a second
1195
- * `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
1196
- * it would turn a reload into a failure the person cannot clear, so the entry is
1197
- * dropped and the file gets a fresh id.
1198
- */
1199
- async function claimId(api, session, file) {
1200
- const remembered = recallUploadId(session.scope, file);
1201
- if (remembered === void 0) return {
1202
- id: randomUploadId(),
1203
- complete: false
1204
- };
1205
- const info = await api.uploadInfo(remembered).catch(() => void 0);
1206
- if (info?.complete === true) return {
1207
- id: remembered,
1208
- complete: true
1209
- };
1210
- if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
1211
- session.tried.add(file);
1212
- return {
1213
- id: remembered,
1214
- complete: false
1215
- };
1216
- }
1217
- forgetUploadId(session.scope, file);
1218
- return {
1219
- id: randomUploadId(),
1220
- complete: false
1221
- };
1222
- }
1223
- /**
1224
- * Replace every `File` in a submitted form with the id of a stored upload,
1225
- * reporting how far each one has got.
1218
+ * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
1219
+ * A key derived from the input collides the moment two people submit the same
1220
+ * thing, and they then recover each other's runs; it also carries what they
1221
+ * typed into a lookup token the platform deliberately stopped logging.
1222
+ * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
1223
+ * `POST /workflows/runs` allows a key.
1224
+ * - **Minted once per load and written back for the next one**, which is the
1225
+ * whole mechanism: the load that presses the button records the key with the
1226
+ * run, and the load after it finds the run by producing the same key.
1226
1227
  *
1227
- * Sequential rather than `Promise.all`: these are large bodies, and a form with
1228
- * two 200 MB recordings should send them one after another rather than compete
1229
- * for the same connection. That is also what makes a single bar honest — one
1230
- * file is in flight at a time, and `index`/`count` say which.
1228
+ * Storage rather than the page's own URL, for all of them. A `?key=` parameter
1229
+ * survives more (a new tab, a bookmark, a shared link) and that is the problem:
1230
+ * a URL is pasted into chats, copied into referrers and kept in history, and
1231
+ * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
1232
+ * it. An app with accounts should pass the ACCOUNT's own id here instead, and
1233
+ * then a run follows the person to a new device, which is a promise only a login
1234
+ * can keep.
1231
1235
  *
1232
- * Anything that is not a `File` (or an array of them) passes through untouched,
1233
- * so this is invisible to every form that has none — including one whose values
1234
- * are not an object at all, which `submit` accepts.
1236
+ * ## The storage is the caller's decision, and it is not a detail
1235
1237
  *
1236
- * ## `uploadStream`, not `upload`, and the id is the reason
1238
+ * `"session"` (the default) dies with the tab, which covers exactly the
1239
+ * interruption most pages have — a reload, a same-tab navigation, a crashed tab
1240
+ * — and is the same lifetime as this package's other two stores, the session
1241
+ * resume id (`session-resume-store.ts`) and the upload recall
1242
+ * (`_upload-recall.ts`), so both halves of a reload make the same promise.
1237
1243
  *
1238
- * The difference between the two calls is only who mints the id — and that is
1239
- * exactly what decides whether an interrupted upload can be picked up again. An
1240
- * `upload` mints its own at the END and hands it back, so a caller whose upload
1241
- * died has nothing to name what was stored and no choice but to send the file
1242
- * again. A `uploadStream` is told the id up front, so the windows already in the
1243
- * store are addressable, which is what both a pause and a server restart need.
1244
+ * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
1245
+ * between digests and may live a month, where closing the browser on Tuesday and
1246
+ * coming back on Friday to press Stop is the ordinary case rather than an edge
1247
+ * one, and a tab-scoped key would answer that with an empty form beside a run
1248
+ * still posting somewhere. It is as far as a key can go without a login, and no
1249
+ * further. `podcast-digest` is that template, and the reason this hook is still
1250
+ * called by name anywhere; the other five take the tab-scoped default the
1251
+ * submit hook mints for them.
1244
1252
  *
1245
- * Nothing else about the submission changes: the run is still started after the
1246
- * last byte lands, so the incomplete record a streamed upload leaves along the
1247
- * way is one nobody reads.
1253
+ * ## Anything ELSE a page stores back must be VALIDATED on read
1254
+ *
1255
+ * This key needs no validation, and it is worth saying why, because it is the
1256
+ * exception: any string is a legal key, so a value from storage can only fail to
1257
+ * match a run. A page that remembers something more — which MODE submitted, say
1258
+ * — is remembering a value it will turn into a name, and storage hands back a
1259
+ * string some earlier version of that page wrote: a renamed mode, a hand-edited
1260
+ * value, a slot another app on the origin happens to share. Unchecked, that
1261
+ * starts a run called `undefined` and answers a 400 nobody typed. Check it
1262
+ * against the page's own list on the way out (`recalledMode` in
1263
+ * `transcription-workflow/recover.ts` is the worked example) — the recall is the
1264
+ * page's, the validation is not optional.
1265
+ *
1266
+ * ## The slot is keyed by the page's own URL
1267
+ *
1268
+ * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
1269
+ * would have two agents scaffolded from the same template recover each other's
1270
+ * runs. The key is the page's own directory — resolved through `"./"`, which
1271
+ * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
1272
+ * find the same key. Same call `session-resume-store.ts` makes, for the same
1273
+ * reason.
1274
+ *
1275
+ * One key per PAGE is right even for a page driving several workflows: `find` is
1276
+ * scoped by workflow as well as by key, so three hooks sharing one key recover
1277
+ * three separate runs. `transcription-workflow` is that page.
1278
+ *
1279
+ * Every access is guarded. Storage THROWS outright in some contexts (Safari
1280
+ * private mode, an iframe blocked by policy) and is ABSENT in others (any
1281
+ * server-side render), and a page that cannot remember its key must degrade to
1282
+ * the behaviour it would have had anyway — one run per load — rather than
1283
+ * failing to render.
1248
1284
  */
1249
- async function uploadFiles(api, input, report, parallel, session) {
1250
- if (!isRecord(input)) return input;
1251
- const entries = Object.entries(input);
1252
- const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
1253
- let index = 0;
1254
- const store = async (file) => {
1255
- index += 1;
1256
- const done = session.stored.get(file);
1257
- if (done !== void 0) return done;
1258
- const position = {
1259
- name: file.name,
1260
- index,
1261
- count
1262
- };
1263
- const known = session.ids.get(file);
1264
- const claimed = known === void 0 ? await claimId(api, session, file) : {
1265
- id: known,
1266
- complete: false
1267
- };
1268
- const id = claimed.id;
1269
- if (known === void 0) {
1270
- session.ids.set(file, id);
1271
- rememberUploadId(session.scope, file, id);
1272
- }
1273
- if (claimed.complete) {
1274
- report({
1275
- ...position,
1276
- loaded: file.size,
1277
- total: file.size,
1278
- fraction: 1,
1279
- paused: false
1280
- });
1281
- session.stored.set(file, id);
1282
- return id;
1283
- }
1284
- await sendThroughGate(session.gate, async (resume) => {
1285
- await api.uploadStream(id, file, {
1286
- name: file.name,
1287
- signal: session.gate.signal,
1288
- onProgress: (progress) => report({
1289
- ...position,
1290
- ...progress,
1291
- paused: session.gate.paused
1292
- }),
1293
- ...omitUndefined({
1294
- parallel,
1295
- resume: resume || session.tried.has(file) ? true : void 0
1296
- })
1297
- });
1298
- });
1299
- session.stored.set(file, id);
1300
- return id;
1301
- };
1302
- const out = {};
1303
- for (const [name, value] of entries) {
1304
- if (value instanceof File) {
1305
- out[name] = await store(value);
1306
- continue;
1307
- }
1308
- const chosen = filesOf(value);
1309
- if (chosen.length === 0) {
1310
- out[name] = value;
1311
- continue;
1312
- }
1313
- const ids = [];
1314
- for (const file of chosen) ids.push(await store(file));
1315
- out[name] = ids;
1285
+ /** Where a run key lives, namespaced like this package's two other stores. */
1286
+ const PREFIX$1 = "aai:run-key:";
1287
+ /** This page's own slot — see "The slot is keyed by the page's own URL". */
1288
+ function slotFor() {
1289
+ const href = globalThis.location?.href;
1290
+ if (href === void 0) return PREFIX$1;
1291
+ try {
1292
+ return `${PREFIX$1}${new URL("./", href).href}`;
1293
+ } catch {
1294
+ return `${PREFIX$1}${href}`;
1316
1295
  }
1317
- return out;
1318
1296
  }
1319
- //#endregion
1320
- //#region _upload-pause.ts
1321
1297
  /**
1322
- * The two buttons a page puts over a live upload, bound to whichever gate the
1323
- * submission is currently holding.
1298
+ * Read the key this page already has, or mint and remember one.
1324
1299
  *
1325
- * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
1326
- * both reported the pause the same way: park the gate, then fold `paused` into
1327
- * the status the bar is already drawing. Folded rather than replaced, because
1328
- * everything else about that status — which file, how far, of how many — is
1329
- * still true. Two copies of that rule are two copies that can stop agreeing
1330
- * about what a paused bar says.
1300
+ * Not exported: a page that wants a key wants it for the life of a component,
1301
+ * which is what the hook is. Calling this per render would mint a fresh key and
1302
+ * hand `recover` one nothing was ever started under.
1331
1303
  */
1304
+ function mintRunKey(storage) {
1305
+ try {
1306
+ const store = storage === "local" ? globalThis.localStorage : globalThis.sessionStorage;
1307
+ const slot = slotFor();
1308
+ const stored = store?.getItem(slot);
1309
+ if (stored !== null && stored !== void 0) return stored;
1310
+ const minted = crypto.randomUUID();
1311
+ store?.setItem(slot, minted);
1312
+ return minted;
1313
+ } catch {
1314
+ return crypto.randomUUID();
1315
+ }
1316
+ }
1332
1317
  /**
1333
- * Bind pause/resume to the live submission's gate.
1318
+ * The key `useWorkflowSubmit` uses when the page named none.
1334
1319
  *
1335
- * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
1336
- * stable across submissions rather than being re-created per gate.
1337
- * @param setUpload - The status setter the progress bar renders from.
1320
+ * Two things it does that a plain `useRunKey()` at the call site cannot, and
1321
+ * both are about a page that DID name one:
1322
+ *
1323
+ * - **It mints nothing when the caller has a key.** Minting writes to storage,
1324
+ * so an unconditional `useRunKey()` inside the hook would leave a slot behind
1325
+ * on every page that passes an account id and never reads it back.
1326
+ * - **It stays reactive to the caller's key.** A key that arrives late — an
1327
+ * account id resolved after a login — must reach the lookup, which re-asks on
1328
+ * a changed key by design; freezing it into `useState` would pin the page to
1329
+ * whatever it held on its first render.
1330
+ *
1331
+ * The minted half is still frozen for the component's life, which is what
1332
+ * `useRunKey` freezes it for: a fresh key per render would record every run
1333
+ * under a name the next load cannot produce.
1334
+ *
1335
+ * @param explicit - The caller's own key, or undefined for a page with none.
1336
+ * @returns The key to record runs under and look them up by.
1338
1337
  *
1339
1338
  * @internal
1340
1339
  */
1341
- function useUploadPause(getGate, setUpload) {
1342
- const setPaused = useCallback((paused) => {
1343
- setUpload((current) => current ? {
1344
- ...current,
1345
- paused
1346
- } : current);
1347
- }, [setUpload]);
1348
- return {
1349
- pauseUpload: useCallback(() => {
1350
- getGate()?.pause();
1351
- setPaused(true);
1352
- }, [getGate, setPaused]),
1353
- resumeUpload: useCallback(() => {
1354
- getGate()?.resume();
1355
- setPaused(false);
1356
- }, [getGate, setPaused])
1357
- };
1340
+ function useDefaultRunKey(explicit) {
1341
+ const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : crypto.randomUUID());
1342
+ return explicit ?? minted;
1358
1343
  }
1359
- //#endregion
1360
- //#region use-workflow-form.ts
1361
1344
  /**
1362
- * The two hooks a FORM needs, as against the one a status view does.
1345
+ * A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
1363
1346
  *
1364
- * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
1365
- * These two are what comes before it: `useWorkflows` reads the declared
1366
- * workflows so `<WorkflowFields>` can render a form from a schema, and
1367
- * `useWorkflowSubmit` starts a run and hands the id straight to
1368
- * `useWorkflowRun`.
1347
+ * @param options - See the module doc for the whole argument. The storage kind
1348
+ * is read once, when the key is minted: a value that changed afterwards would
1349
+ * be asking to move a key that has already been recorded with a run.
1350
+ * @returns The key to record runs under and to look them up by — the same one
1351
+ * for the life of the component, and for the next load in the same tab (or the
1352
+ * same browser, under `"local"`).
1353
+ */
1354
+ function useRunKey(options = {}) {
1355
+ const { storage = "session" } = options;
1356
+ const [key] = useState(() => mintRunKey(storage));
1357
+ return key;
1358
+ }
1359
+ //#endregion
1360
+ //#region src/_recover-run.ts
1361
+ /**
1362
+ * Finding a run again when the page has lost its id.
1369
1363
  *
1370
- * ## `useWorkflowSubmit` — a form's two halves in one hook
1364
+ * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
1365
+ * which was only half true of the hooks above it: the run id lived in plain
1366
+ * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
1367
+ * live run with nothing anywhere able to name it. The run really did continue;
1368
+ * the person really could not get back to it.
1371
1369
  *
1372
- * A page that submits a workflow always needs the same four pieces of state:
1373
- * the run id, whether a submit is in flight, whether the RUN is still going, and
1374
- * whichever of the two failed. `link-digest` writes them out by hand, which is
1375
- * the right shape for a template teaching the primitives and the wrong shape to
1376
- * write a third time — and it is easy to get subtly wrong: dropping the previous
1377
- * run id before the new `POST` returns is what stops a finished result sitting
1378
- * under a form that is already submitting again.
1370
+ * `StartOptions.key` is the handle that survives that, and it always was — a
1371
+ * caller's own name for a run, indexed by the agent, read back with
1372
+ * `find(workflow, key)`. What was missing is the two lines that ASK. This is
1373
+ * them, plus the four decisions they turn out to carry.
1379
1374
  *
1380
- * So this is `api.start` plus {@link useWorkflowRun}, with the state between
1381
- * them. It adds no transport of its own and holds no run state of its own; the
1382
- * watching (stream first, poll as its fallback, terminal stops) is entirely
1383
- * `useWorkflowRun`'s, and `run` here IS its run.
1375
+ * ## It is a MOUNT-time act, not "whenever there is no run"
1384
1376
  *
1385
- * ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
1377
+ * The tempting spelling is "if we hold no run id, look one up", and it breaks
1378
+ * `reset()`: a form put back to its initial state holds no run id, so the next
1379
+ * pass would re-adopt the very run the person had just dismissed — a Clear
1380
+ * button that clears nothing. So the lookup runs once per mount (and again only
1381
+ * if the KEY changes, which is a different person's run), and every later
1382
+ * absence of a run id is taken at face value.
1386
1383
  *
1387
- * `api.startAndWait` would collapse this to one request, and it is the wrong
1388
- * default for a page: it holds a socket open for up to a minute, answers nothing
1389
- * until it settles, and a page has `useWorkflowRun` — which survives a reload,
1390
- * shows progress, and costs one stream. The synchronous call is for callers with
1391
- * nowhere to put a watch (a script, a cron, a form POST from a server). Pass
1392
- * `wait` here when the page really does want one request, and the run is
1393
- * followed from the same id either way.
1394
- */
1395
- /**
1396
- * Read the agent's declared workflows.
1384
+ * ## The lookup NEVER wins a race against a submit
1397
1385
  *
1398
- * What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
1399
- * Schema of that workflow's input, converted server-side precisely so a browser
1400
- * can read it.
1386
+ * A person who reloads and immediately submits has started the run they want,
1387
+ * and an answer that was already in flight names an older one. The caller
1388
+ * therefore adopts through `current ?? found`: the recovered id fills an empty
1389
+ * slot and never replaces a full one.
1401
1390
  *
1402
- * The failure is reported rather than swallowed, because the alternative is an
1403
- * empty list — which renders as a form with no fields and reads as "this agent
1404
- * declares no workflows" about an agent that was merely unreachable.
1391
+ * ## A failed lookup is REPORTED
1405
1392
  *
1406
- * @example
1407
- * ```tsx
1408
- * import { useWorkflows } from "@alexkroman1/aai-ui";
1393
+ * The alternative is a page that quietly shows an empty form to somebody whose
1394
+ * run is live, who then starts a second one — the duplicated work the key
1395
+ * exists to prevent, and on a workflow app that is real money. A person who has
1396
+ * never run anything pays a banner they can ignore. Same trade as
1397
+ * `useWorkflows`, for the same reason: an empty answer here is a confident
1398
+ * false statement.
1409
1399
  *
1410
- * // A page rendering its own chrome from the listing — a picker, say. A form
1411
- * // for ONE workflow wants `<WorkflowFields workflow="name" />` instead,
1412
- * // which does this lookup itself.
1413
- * function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) {
1414
- * const { workflows, loading, error } = useWorkflows();
1415
- * if (loading) return <p>Loading…</p>;
1416
- * if (error !== undefined) return <p role="alert">{error}</p>;
1417
- * return (
1418
- * <ul>
1419
- * {workflows.map((summary) => (
1420
- * <li key={summary.name}>
1421
- * <button type="button" onClick={() => onPick(summary.name)}>
1422
- * {summary.description ?? summary.name}
1423
- * </button>
1424
- * </li>
1425
- * ))}
1426
- * </ul>
1427
- * );
1428
- * }
1429
- * ```
1400
+ * ## It is ON by default, and it used to be opt-in
1430
1401
  *
1431
- * @param opts - See {@link UseWorkflowsOptions}.
1432
- * @returns The listing, its loading flag and its failure — see
1433
- * {@link UseWorkflowsResult}.
1402
+ * The argument for opt-in was that a `key` on its own means only "record this
1403
+ * with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
1404
+ * means, there being no page to put a run back on. A FORM is the other case: it
1405
+ * is the page, and losing the run is the thing it cannot recover from. Six of
1406
+ * six page templates wrote `useRunKey()` and `recover: true` together, which is
1407
+ * the same shape `session-resume-store.ts` names on the voice side — a default
1408
+ * in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
1434
1409
  *
1435
- * @public
1410
+ * `enabled` remains, because `recover: false` remains: a page whose form must
1411
+ * always open empty says so, and then nothing here runs.
1436
1412
  */
1437
- function useWorkflows(opts = {}) {
1438
- const { api, skip = false } = opts;
1439
- const [state, setState] = useState({
1440
- workflows: [],
1441
- loading: !skip,
1442
- error: void 0
1443
- });
1444
- const getClient = useWorkflowApiRef(api);
1413
+ /**
1414
+ * Look up the newest run for a key, once, as the component mounts.
1415
+ *
1416
+ * @param opts - See {@link RecoverRunOptions}.
1417
+ * @returns Whether the lookup is still out. A caller folds it into its own
1418
+ * `pending`, because a form offering Submit while a live run is arriving is a
1419
+ * form inviting a second one.
1420
+ *
1421
+ * @internal
1422
+ */
1423
+ function useRecoveredRun(opts) {
1424
+ const { workflow, key, enabled, getClient } = opts;
1425
+ const [recovering, setRecovering] = useState(enabled && key !== void 0);
1426
+ const handlers = useRef(opts);
1427
+ handlers.current = opts;
1445
1428
  useEffect(() => {
1446
- if (skip) return;
1429
+ if (!enabled || key === void 0) return;
1447
1430
  let cancelled = false;
1448
- getClient().list().then((workflows) => {
1449
- if (!cancelled) setState({
1450
- workflows,
1451
- loading: false,
1452
- error: void 0
1453
- });
1431
+ setRecovering(true);
1432
+ getClient().find(workflow, key, { limit: 1 }).then((found) => {
1433
+ if (cancelled) return;
1434
+ const newest = found[0];
1435
+ if (newest !== void 0) handlers.current.onFound(newest.runId);
1436
+ setRecovering(false);
1454
1437
  }).catch((err) => {
1455
1438
  if (cancelled) return;
1456
- setState({
1457
- workflows: [],
1458
- loading: false,
1459
- error: errorMessage(err)
1460
- });
1439
+ handlers.current.onError(errorMessage(err));
1440
+ setRecovering(false);
1461
1441
  });
1462
1442
  return () => {
1463
1443
  cancelled = true;
1464
1444
  };
1465
- }, [skip, getClient]);
1466
- return state;
1445
+ }, [
1446
+ enabled,
1447
+ key,
1448
+ workflow,
1449
+ getClient
1450
+ ]);
1451
+ return recovering;
1467
1452
  }
1453
+ //#endregion
1454
+ //#region src/_run-controls.ts
1468
1455
  /**
1469
- * Start a workflow from a form, and follow the run it creates.
1470
- *
1471
- * @typeParam D - The workflow DEFINITION, which types both halves of the
1472
- * submission: `submit(input)` takes what the workflow's schema parses to, and
1473
- * `run.status === "completed"` narrows to a typed `run.output`.
1456
+ * The two things a page does TO a run it started, bound to the run it has.
1474
1457
  *
1475
- * It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
1476
- * already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
1477
- * `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
1478
- * 400 in the browser. Naming the def instead types the input from the same
1479
- * declaration — and `import type` is ERASED, so it costs the bundle nothing.
1480
- * Passing an output type where a def belongs is now a compile error rather
1481
- * than a silent loss of typing, which is the point.
1458
+ * `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
1459
+ * handed it back, so a page that wanted "send it now" or "stop" had to hold an
1460
+ * `api` of its own purely to write `api.wake(runId)` — which is the whole reason
1461
+ * the two raw-primitive template pages keep a client at module scope. That is a
1462
+ * page carrying the transport to make up for a hook withholding its own state.
1482
1463
  *
1483
- * @example
1484
- * ```tsx no-check
1485
- * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
1486
- * import type { digest } from "./agent.ts";
1464
+ * Both calls answer rather than fail when there is nothing to act on — `0`
1465
+ * sleeps ended, `false` this call did not end it — which is the SDK's own
1466
+ * contract for them (two tabs pressing Stop is ordinary), and it is what lets
1467
+ * the no-run case be the same answer rather than a special one a caller has to
1468
+ * branch on.
1469
+ */
1470
+ /**
1471
+ * Bind `wake` and `cancel` to whatever run the hook is currently following.
1487
1472
  *
1488
- * function DigestForm() {
1489
- * const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
1490
- * return (
1491
- * <Form onSubmit={(values) => submit(values)} error={error}>
1492
- * <TextField name="url" label="Link" type="url" required />
1493
- * <SubmitButton pending={pending}>Digest</SubmitButton>
1494
- * {run?.status === "completed" && <p>{run.output.title}</p>}
1495
- * </Form>
1496
- * );
1497
- * }
1498
- * ```
1473
+ * @param runId - The live run, or `undefined` before one exists.
1474
+ * @param getClient - The stable getter from `useWorkflowApiRef`.
1475
+ * @returns Two callbacks, stable while `runId` is.
1499
1476
  *
1500
- * @public
1477
+ * @internal
1501
1478
  */
1502
- function useWorkflowSubmit(workflow, opts = {}) {
1503
- const { api, key, recover = false, wait, intervalMs, parallel } = opts;
1504
- const [runId, setRunId] = useState(void 0);
1505
- const [starting, setStarting] = useState(false);
1506
- const [startError, setStartError] = useState(void 0);
1507
- const [upload, setUpload] = useState(void 0);
1508
- const session = useRef(void 0);
1509
- const getClient = useWorkflowApiRef(api);
1510
- const tracked = useWorkflowRun(runId, omitUndefined({
1511
- api,
1512
- intervalMs
1513
- }));
1514
- const { wake, cancel } = useRunControls(runId, getClient);
1515
- const recovering = useRecoveredRun({
1516
- workflow,
1517
- key,
1518
- enabled: recover,
1519
- getClient,
1520
- onFound: (found) => {
1521
- setRunId((current) => current ?? found);
1522
- },
1523
- onError: setStartError
1524
- });
1525
- const submit = useCallback(async (input) => {
1526
- const client = getClient();
1527
- setStarting(true);
1528
- setStartError(void 0);
1529
- setRunId(void 0);
1530
- session.current?.gate.cancel();
1531
- const current = createUploadSession(workflow);
1532
- session.current = current;
1533
- try {
1534
- const options = omitUndefined({ key });
1535
- const started = await uploadFiles(client, input, setUpload, parallel, current);
1536
- setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
1537
- ...options,
1538
- wait
1539
- })).runId);
1540
- } catch (err) {
1541
- if (!current.gate.cancelled) setStartError(errorMessage(err));
1542
- } finally {
1543
- if (session.current === current) {
1544
- session.current = void 0;
1545
- setStarting(false);
1546
- setUpload(void 0);
1547
- }
1548
- }
1549
- }, [
1550
- workflow,
1551
- key,
1552
- wait,
1553
- parallel,
1554
- getClient
1555
- ]);
1556
- const reset = useCallback(() => {
1557
- session.current?.gate.cancel();
1558
- session.current = void 0;
1559
- setRunId(void 0);
1560
- setStartError(void 0);
1561
- setUpload(void 0);
1562
- }, []);
1563
- const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => session.current?.gate, []), setUpload);
1479
+ function useRunControls(runId, getClient) {
1564
1480
  return {
1565
- submit,
1566
- submitForm: submit,
1567
- reset,
1568
- wake,
1569
- cancel,
1570
- pauseUpload,
1571
- resumeUpload,
1572
- run: tracked.run,
1573
- pending: recovering || starting || tracked.polling,
1574
- upload,
1575
- error: startError ?? tracked.error
1481
+ wake: useCallback(async () => {
1482
+ if (runId === void 0) return 0;
1483
+ return await getClient().wake(runId);
1484
+ }, [runId, getClient]),
1485
+ cancel: useCallback(async () => {
1486
+ if (runId === void 0) return false;
1487
+ return await getClient().cancel(runId);
1488
+ }, [runId, getClient])
1576
1489
  };
1577
1490
  }
1578
1491
  //#endregion
1579
- //#region components/workflow-fields.tsx
1580
- /** @jsxImportSource react */
1492
+ //#region src/_upload-recall.ts
1581
1493
  /**
1582
- * A form built from a workflow's declared input schema.
1583
- *
1584
- * `GET workflows` reports each workflow's `inputSchema` as JSON Schema — the
1585
- * zod schema an author wrote in `agent.ts`, converted at listing time precisely
1586
- * so a browser can read it. This is what reads it: one `<WorkflowFields>` and a
1587
- * workflow's form matches its schema by construction, so adding a field to the
1588
- * schema adds it to the page and nothing can drift.
1589
- *
1590
- * ## It covers SCALARS, and says so rather than guessing
1494
+ * Where an upload's ID survives a page RELOAD.
1591
1495
  *
1592
- * A string, number, integer, boolean or enum has one obvious control each. A
1593
- * nested object or an array does not — every choice (a JSON textarea, a repeater,
1594
- * a comma-separated string) is a guess about what the author meant, and a guess
1595
- * that produces a value the schema then rejects is worse than no field at all.
1596
- * So those are SKIPPED, and the fields for them are written by hand: every field
1597
- * in this package is a plain named control, so a hand-written one composes with
1598
- * a generated one inside the same {@link Form}.
1599
- */
1600
- /**
1601
- * Render one field per scalar property of a workflow's input schema.
1496
+ * A streamed upload is resumable because its id outlives the attempt that began
1497
+ * it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
1498
+ * pause a person can press. Both of them hold the id in MEMORY: the walk's
1499
+ * `UploadSession` lives in a `useRef`, so a reload was the one interruption the
1500
+ * mechanism could not survive. Everything else was already in place — the windows
1501
+ * were still in the store, the agent could still name them
1502
+ * (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
1503
+ * had thrown away the only name for them. So a person who refreshed at 90% of a
1504
+ * 200 MB recording sent the whole file again, which is the one interruption they
1505
+ * are most likely to cause on purpose.
1602
1506
  *
1603
- * Pass the workflow's NAME and the schema is fetched here; pass a
1604
- * {@link WorkflowSummary} you already hold and nothing is fetched. The name form
1605
- * is the one a page usually wants — it is the same string the submit hook takes,
1606
- * and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
1607
- * folding that lookup's error into the form's) whose only product is this
1608
- * component's argument.
1507
+ * This is that name, written down. It is what tus-js-client's `urlStorage` and
1508
+ * Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
1509
+ * uses for a session id.
1609
1510
  *
1610
- * Renders nothing when the workflow declared no schema — a workflow with no
1611
- * declared input takes anything, and a form for "anything" is not a form — and
1612
- * nothing while a named lookup is still in flight, so the hand-written fields
1613
- * beside it are not reordered when the schema lands.
1511
+ * ## A FINGERPRINT, because a `File` has no name a page can address
1614
1512
  *
1615
- * @example
1616
- * ```tsx no-check
1617
- * import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
1618
- * from "@alexkroman1/aai-ui";
1619
- * import type { transcribe } from "./agent.ts";
1513
+ * A file from a picker carries no path and no handle, so the key is what
1514
+ * tus-js-client fingerprints on: size, last-modified, type and name. Two
1515
+ * different files agreeing on all four is the case this cannot tell apart — and
1516
+ * the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
1517
+ * what the id actually holds before sending a byte to it, so a wrong hit costs
1518
+ * one `GET` and a fresh id rather than a corrupted upload.
1620
1519
  *
1621
- * function StartRun() {
1622
- * const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe");
1623
- * return (
1624
- * <Form onSubmit={submitForm} error={error}>
1625
- * <WorkflowFields workflow="transcribe" />
1626
- * <SubmitButton pending={pending}>Transcribe</SubmitButton>
1627
- * </Form>
1628
- * );
1629
- * }
1630
- * ```
1520
+ * ## `sessionStorage`, deliberately
1631
1521
  *
1632
- * @param props - Field-set props.
1522
+ * The same call `session-resume-store.ts` makes, for a reason that happens to be
1523
+ * stronger here: a reload and a same-tab navigation are exactly what this is for,
1524
+ * and an id from yesterday names an upload the agent's sweep has very likely
1525
+ * already collected. A tab is also the boundary the walk itself has — two tabs
1526
+ * uploading the same recording are two submissions.
1633
1527
  *
1634
- * @public
1528
+ * Every access is guarded. Storage throws outright in Safari private mode and
1529
+ * under a blocking policy, and an upload that cannot be REMEMBERED must degrade
1530
+ * to the upload we would have done anyway rather than failing to start.
1635
1531
  */
1636
- function WorkflowFields({ workflow }) {
1637
- const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1638
- const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
1639
- useDeclareFieldsPending(loading);
1640
- const schema = asObjectSchema(summary?.inputSchema);
1641
- if (!schema?.properties) return null;
1642
- const required = new Set(schema.required ?? []);
1643
- const uploads = new Set(summary?.uploads ?? []);
1644
- return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
1645
- name,
1646
- property,
1647
- required: required.has(name),
1648
- upload: uploads.has(name)
1649
- }, name)) });
1650
- }
1651
- /** One property's control, or nothing when its type has no obvious one. */
1652
- function SchemaField({ name, property, required, upload = false }) {
1653
- const label = humanize(name);
1654
- const hint = property.description === void 0 ? {} : { hint: property.description };
1655
- const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
1656
- if (upload) return /* @__PURE__ */ jsx(FileField, {
1657
- name,
1658
- label,
1659
- required,
1660
- upload: true,
1661
- ...hint
1662
- });
1663
- if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
1664
- name,
1665
- label,
1666
- required,
1667
- options: property.enum.map((value) => String(value)),
1668
- ...hint,
1669
- ...defaults
1670
- });
1671
- const type = typeOf(property);
1672
- switch (type) {
1673
- case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
1674
- name,
1675
- label,
1676
- defaultChecked: property.default === true,
1677
- ...hint
1678
- });
1679
- case "number":
1680
- case "integer": return /* @__PURE__ */ jsx(NumberField, {
1681
- name,
1682
- label,
1683
- required,
1684
- step: type === "integer" ? 1 : "any",
1685
- ...hint,
1686
- ...defaults
1687
- });
1688
- case "string": return /* @__PURE__ */ jsx(TextField, {
1689
- name,
1690
- label,
1691
- required,
1692
- ...hint,
1693
- ...defaults
1694
- });
1695
- default: return null;
1696
- }
1697
- }
1698
- /** A property's type, taking the first non-null member of a union. */
1699
- function typeOf(property) {
1700
- const { type } = property;
1701
- if (typeof type === "string") return type;
1702
- return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
1703
- }
1704
- /** The listing's `unknown` schema as the object shape this reads, when it is one. */
1705
- function asObjectSchema(schema) {
1706
- return isRecord(schema) ? schema : void 0;
1707
- }
1532
+ const PREFIX = "aai:upload:";
1708
1533
  /**
1709
- * A property name as a label — `recordingId` → `Recording id`.
1534
+ * How many ids one form keeps.
1710
1535
  *
1711
- * A default, not a policy: a schema whose labels matter should carry a
1712
- * `.describe()`, and an author who wants exact control writes the field.
1536
+ * A cap rather than an expiry, because `sessionStorage` already expires with the
1537
+ * tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
1538
+ * submits a hundred files: the oldest go first, and the id most likely to be
1539
+ * worth resuming is the one written last.
1713
1540
  */
1714
- function humanize(name) {
1715
- const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase();
1716
- return spaced.charAt(0).toUpperCase() + spaced.slice(1);
1541
+ const MAX_REMEMBERED = 32;
1542
+ /** One form's slot in storage. */
1543
+ function keyFor(scope) {
1544
+ return `${PREFIX}${scope}`;
1717
1545
  }
1718
- //#endregion
1719
- //#region components/workflow-progress.tsx
1720
- /** @jsxImportSource react */
1721
1546
  /**
1722
- * What a run has said so far, rendered.
1723
- *
1724
- * The complement of a status line, and the reason both exist: a run is
1725
- * `running` for its whole life, so a one-round job and a ten-round one look
1726
- * identical while they happen. These lines come from the run itself (`report()`
1727
- * in a `"use step"` body), which is the only channel a workflow has before it
1728
- * produces an output.
1729
- *
1730
- * Three rules are baked in, and they are why this is a component rather than
1731
- * three lines each page writes for itself — the two templates that had written
1732
- * it had written all three, comments included:
1733
- *
1734
- * - **It renders nothing until there is something to render.** `supported` is
1735
- * what keeps this from being an empty box forever on an agent deployed before
1736
- * progress streams existed: "wrote nothing yet" and "serves no stream" are
1737
- * indistinguishable from the chunk list alone.
1738
- * - **The lines are TEXT, not elements.** They are append-only and two rounds
1739
- * legitimately produce identical text, so there is no stable per-line key to
1740
- * give React. Joining sidesteps the question instead of suppressing the lint
1741
- * rule that asks it.
1742
- * - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
1743
- * or opening a finished run tomorrow — shows how it got there rather than an
1744
- * empty box. That is `useWorkflowProgress`'s doing; this is what makes it
1745
- * visible.
1746
- *
1747
- * @example
1748
- * ```tsx
1749
- * import { WorkflowProgress } from "@alexkroman1/aai-ui";
1750
- *
1751
- * function RunPanel({ runId }: { runId: string }) {
1752
- * return <WorkflowProgress runId={runId} />;
1753
- * }
1754
- * ```
1755
- *
1756
- * @param props - Progress-log props.
1547
+ * What names this file across a reload.
1757
1548
  *
1758
- * @public
1549
+ * The four fields a browser gives a picked file that do not change between loads.
1550
+ * `name` last because it is the one a person can read in a debugger.
1759
1551
  */
1760
- function WorkflowProgress({ runId, api, className, placeholder, lines }) {
1761
- const { progress, streaming, supported } = useWorkflowProgress(runId, omitUndefined({ api }));
1762
- const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
1763
- if (!supported || shown.length === 0) return placeholder ?? null;
1764
- return /* @__PURE__ */ jsxs("pre", {
1765
- className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
1766
- children: [shown.join("\n"), streaming && "\n…"]
1767
- });
1552
+ function fingerprint(file) {
1553
+ return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
1554
+ }
1555
+ /** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
1556
+ function read(scope) {
1557
+ try {
1558
+ const raw = globalThis.sessionStorage?.getItem(keyFor(scope));
1559
+ if (raw === null || raw === void 0) return {};
1560
+ const parsed = JSON.parse(raw);
1561
+ return isRecord(parsed) ? parsed : {};
1562
+ } catch {
1563
+ return {};
1564
+ }
1565
+ }
1566
+ function write(scope, entries) {
1567
+ try {
1568
+ globalThis.sessionStorage?.setItem(keyFor(scope), JSON.stringify(entries));
1569
+ } catch {}
1768
1570
  }
1769
- //#endregion
1770
- //#region page.tsx
1771
- /** @jsxImportSource react */
1772
1571
  /**
1773
- * `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
1572
+ * The id this file was last being stored under in this tab, if any.
1774
1573
  *
1775
- * The twin of `client()` for an agent whose front door is a form rather than a
1776
- * microphone (`workflowApp()`). It is a separate entry rather than
1777
- * an option on `client()` because of what `client()` unavoidably does: it
1778
- * constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
1779
- * graph, and a microphone request. A flag would have to make all of that
1780
- * conditional, and every session hook would then have to answer "what does this
1781
- * mean with no session?" — so the honest split is two mounts. A page that wants
1782
- * voice uses `client()`; a page that wants neither audio nor a socket uses this.
1574
+ * A hit is a CANDIDATE and never a decision — see the module doc.
1783
1575
  *
1784
- * Authoring is otherwise identical — the file is still `client.tsx`, still
1785
- * React, still Tailwind, still the same theme tokens — so a workflow app reads
1786
- * like every other agent. What it reaches for instead of `useSession()` is
1787
- * `createWorkflowApi()` / `useWorkflowRun()`.
1576
+ * @internal
1788
1577
  */
1578
+ function recallUploadId(scope, file) {
1579
+ const found = read(scope)[fingerprint(file)];
1580
+ return typeof found === "string" ? found : void 0;
1581
+ }
1789
1582
  /**
1790
- * Mount a page for an agent whose work happens in workflows.
1791
- *
1792
- * There is deliberately no session, no microphone, and no socket: the component
1793
- * talks to the agent over the workflow HTTP API
1794
- * (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
1795
- *
1796
- * @example
1797
- * ```tsx
1798
- * import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
1799
- * import { useState } from "react";
1800
- *
1801
- * // Hoisted: a client built in render is a new object every render.
1802
- * const api = createWorkflowApi();
1583
+ * Remember the id this file is being stored under.
1803
1584
  *
1804
- * function App() {
1805
- * const [runId, setRunId] = useState<string>();
1806
- * const { run } = useWorkflowRun(runId, { api });
1807
- * return (
1808
- * <button
1809
- * type="button"
1810
- * onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
1811
- * >
1812
- * {run ? run.status : "Start"}
1813
- * </button>
1814
- * );
1815
- * }
1585
+ * Called before the first byte leaves rather than after the last one lands: the
1586
+ * reload this exists for happens in between, and an id written at the end is an
1587
+ * id written for the one case that did not need it.
1816
1588
  *
1817
- * page({ name: "Digest", component: App });
1818
- * ```
1589
+ * @internal
1590
+ */
1591
+ function rememberUploadId(scope, file, id) {
1592
+ const entries = read(scope);
1593
+ const key = fingerprint(file);
1594
+ delete entries[key];
1595
+ entries[key] = id;
1596
+ const keys = Object.keys(entries);
1597
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
1598
+ write(scope, entries);
1599
+ }
1600
+ /**
1601
+ * Forget it: the agent holds nothing resumable under this id.
1819
1602
  *
1820
- * @throws If the target element is not found in the DOM.
1603
+ * The other half of the agent deciding. Without it a swept upload is re-read on
1604
+ * every submission of the same file for the life of the tab, which is a round
1605
+ * trip spent learning the same 404.
1821
1606
  *
1822
- * @public
1607
+ * @internal
1823
1608
  */
1824
- function page(config) {
1825
- const container = resolveContainer(config.target);
1826
- setPageTitle(config.name);
1827
- return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
1609
+ function forgetUploadId(scope, file) {
1610
+ const entries = read(scope);
1611
+ const key = fingerprint(file);
1612
+ if (!(key in entries)) return;
1613
+ delete entries[key];
1614
+ write(scope, entries);
1828
1615
  }
1829
1616
  //#endregion
1830
- //#region use-download-url.ts
1617
+ //#region src/_upload-session.ts
1618
+ /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
1619
+ function isAbortError(err) {
1620
+ return err instanceof Error && err.name === "AbortError";
1621
+ }
1622
+ /** A fresh upload id: a capability, so it is random rather than derived. */
1623
+ function randomUploadId() {
1624
+ return crypto.randomUUID().replaceAll("-", "");
1625
+ }
1831
1626
  /**
1832
- * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
1833
- * `<img>` or `<a download>` will accept.
1834
- *
1835
- * `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
1836
- * same bearer every workflow route does, and neither `<audio src>` nor
1837
- * `<a href>` can send one. So every page that plays back what a run WROTE ends
1838
- * up at the same four lines — `download` → `createObjectURL` → state — and the
1839
- * two that are really the point are the two the four lines are wrapped in:
1627
+ * Send one file, waiting out however many pauses the person takes.
1840
1628
  *
1841
- * - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
1842
- * life of the DOCUMENT. Miss it and every completed run's audio stays resident
1843
- * until the tab closes, which on a page people run all day is a leak measured
1844
- * in the size of the files.
1845
- * - **A `cancelled` flag.** A second run settling while the first download is
1846
- * still in flight otherwise sets state from the stale one, and the page plays
1847
- * the previous run's audio under the current run's transcript — a wrong answer
1848
- * that looks like a right one.
1629
+ * The loop from the module doc, written once: both hooks need exactly this and a
1630
+ * second copy of it is a second place for the abort/pause distinction to be got
1631
+ * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
1632
+ * false the first time, since a fresh id has nothing to resume and saying
1633
+ * otherwise waives the refusal that makes a caller-chosen id safe.
1849
1634
  *
1850
- * Two templates had written this hook, identically, doc paragraph included, and
1851
- * `aai-ui` exported no download helper at all. Both also faked `pending` by
1852
- * checking `url === undefined && error === undefined`, which reads "idle" and
1853
- * "downloading" as the same thing — so this reports it.
1635
+ * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
1636
+ * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
1637
+ * how an abandoned submission unwinds without being reported as a failure.
1854
1638
  */
1855
- /** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
1856
- const IDLE = { pending: false };
1857
- /** Bytes in flight. Shared for the same reason as {@link IDLE}. */
1858
- const PENDING = { pending: true };
1639
+ async function sendThroughGate(gate, send) {
1640
+ let tried = false;
1641
+ for (;;) {
1642
+ await gate.settle();
1643
+ if (gate.cancelled) throw new Error("Upload cancelled.");
1644
+ const resume = tried;
1645
+ tried = true;
1646
+ try {
1647
+ await send(resume);
1648
+ return;
1649
+ } catch (err) {
1650
+ if (gate.cancelled || !isAbortError(err)) throw err;
1651
+ }
1652
+ }
1653
+ }
1859
1654
  /**
1860
- * Read an upload's bytes and hand back a URL a DOM element can use.
1861
- *
1862
- * @param uploadId - The id a completed run reported, or `undefined` before one
1863
- * exists — which is what a page passes straight through while it waits, and
1864
- * reports as idle rather than pending.
1865
- * @param opts - See {@link UseDownloadUrlOptions}.
1866
- * @returns See {@link UseDownloadUrlResult}.
1867
- *
1868
- * @example
1869
- * ```tsx no-check
1870
- * import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
1871
- * import type { spokenSummary } from "./agent.ts";
1872
- *
1873
- * function Playback() {
1874
- * const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
1875
- * const output = run?.status === "completed" ? run.output : undefined;
1876
- * const audio = useDownloadUrl(output?.audio);
1877
- * if (audio.pending) return <p>Fetching audio…</p>;
1878
- * if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
1879
- * return audio.url === undefined ? null : (
1880
- * <a href={audio.url} download="summary.mp3">
1881
- * Download
1882
- * </a>
1883
- * );
1884
- * }
1885
- * ```
1655
+ * A gate, open.
1886
1656
  *
1887
- * @public
1657
+ * One per upload rather than one per hook: the id and the windows already stored
1658
+ * belong to a file, so a gate that outlived its file would resume something else.
1888
1659
  */
1889
- function useDownloadUrl(uploadId, opts = {}) {
1890
- const [state, setState] = useState(IDLE);
1891
- const getClient = useWorkflowApiRef(opts.api);
1892
- useEffect(() => {
1893
- if (uploadId === void 0) {
1894
- setState(IDLE);
1895
- return;
1896
- }
1897
- let cancelled = false;
1898
- let objectUrl;
1899
- setState(PENDING);
1900
- getClient().download(uploadId).then((blob) => {
1660
+ function createUploadGate() {
1661
+ let controller = new AbortController();
1662
+ let paused = false;
1663
+ let cancelled = false;
1664
+ let open;
1665
+ let closed;
1666
+ return {
1667
+ get paused() {
1668
+ return paused;
1669
+ },
1670
+ get cancelled() {
1671
+ return cancelled;
1672
+ },
1673
+ get signal() {
1674
+ return controller.signal;
1675
+ },
1676
+ pause() {
1677
+ if (paused || cancelled) return;
1678
+ paused = true;
1679
+ const gate = Promise.withResolvers();
1680
+ closed = gate.promise;
1681
+ open = gate.resolve;
1682
+ controller.abort();
1683
+ },
1684
+ resume() {
1685
+ if (!paused || cancelled) return;
1686
+ paused = false;
1687
+ controller = new AbortController();
1688
+ open?.();
1689
+ open = void 0;
1690
+ closed = void 0;
1691
+ },
1692
+ cancel() {
1901
1693
  if (cancelled) return;
1902
- objectUrl = URL.createObjectURL(blob);
1903
- setState({
1904
- url: objectUrl,
1905
- pending: false
1906
- });
1907
- }).catch((err) => {
1908
- if (!cancelled) setState({
1909
- error: errorMessage(err),
1910
- pending: false
1911
- });
1912
- });
1913
- return () => {
1914
1694
  cancelled = true;
1915
- if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
1916
- };
1917
- }, [uploadId, getClient]);
1918
- return state;
1695
+ paused = false;
1696
+ controller.abort();
1697
+ open?.();
1698
+ open = void 0;
1699
+ closed = void 0;
1700
+ },
1701
+ async settle() {
1702
+ if (closed) await closed;
1703
+ }
1704
+ };
1919
1705
  }
1920
1706
  //#endregion
1921
- //#region use-run-key.ts
1707
+ //#region src/_workflow-files.ts
1922
1708
  /**
1923
- * The handle a page keeps on the runs it started, across a reload.
1924
- *
1925
- * `useWorkflowSubmit({ key, recover: true })` is what makes a run survivable —
1926
- * the run id is that hook's own state, so a refresh loses it while the run
1927
- * carries on — and the `key` is deliberately the caller's to choose, because it
1928
- * is a lookup CAPABILITY: there is no per-user filtering behind `find`, so the
1929
- * key IS the scoping mechanism. Choosing one is easy to get wrong in three
1930
- * separate ways, and six shipped templates had each written the same twenty
1931
- * lines to get it right. This is those lines.
1932
- *
1933
- * ## Three properties, and the rejected alternatives are why each one matters
1934
- *
1935
- * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
1936
- * A key derived from the input collides the moment two people submit the same
1937
- * thing, and they then recover each other's runs; it also carries what they
1938
- * typed into a lookup token the platform deliberately stopped logging.
1939
- * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
1940
- * `POST /workflows/runs` allows a key.
1941
- * - **Minted once per load and written back for the next one**, which is the
1942
- * whole mechanism: the load that presses the button records the key with the
1943
- * run, and the load after it finds the run by producing the same key.
1709
+ * Which of a submitted form's values are FILES.
1944
1710
  *
1945
- * Storage rather than the page's own URL, for all of them. A `?key=` parameter
1946
- * survives more (a new tab, a bookmark, a shared link) and that is the problem:
1947
- * a URL is pasted into chats, copied into referrers and kept in history, and
1948
- * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
1949
- * it. An app with accounts should pass the ACCOUNT's own id here instead, and
1950
- * then a run follows the person to a new device, which is a promise only a login
1951
- * can keep.
1711
+ * Its own module because both submit hooks need the identical answer and then do
1712
+ * two different things with it — `useWorkflowSubmit` stores each file and passes
1713
+ * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
1714
+ * A second copy of this predicate would be a form field that one hook treats as a
1715
+ * file and the other does not, which is invisible until the run reads the wrong
1716
+ * kind of string.
1717
+ */
1718
+ /**
1719
+ * The files a submitted field carries, if that is what it carries.
1952
1720
  *
1953
- * ## The storage is the caller's decision, and it is not a detail
1721
+ * An array counts only when it is files ALL the way through — a mixed array is
1722
+ * some other field's value that happens to contain one, and turning half of it
1723
+ * into ids would corrupt it silently.
1724
+ */
1725
+ function filesOf(value) {
1726
+ if (value instanceof File) return [value];
1727
+ if (!Array.isArray(value)) return [];
1728
+ const files = value.filter((one) => one instanceof File);
1729
+ return files.length > 0 && files.length === value.length ? files : [];
1730
+ }
1731
+ /**
1732
+ * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
1733
+ * being sent.
1954
1734
  *
1955
- * `"session"` (the default) dies with the tab, which covers exactly the
1956
- * interruption most pages have — a reload, a same-tab navigation, a crashed tab
1957
- * — and is the same lifetime as this package's other two stores, the session
1958
- * resume id (`session-resume-store.ts`) and the upload recall
1959
- * (`_upload-recall.ts`), so both halves of a reload make the same promise.
1735
+ * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
1736
+ * own enumerable properties. So a File left in a payload does not fail to send: it
1737
+ * arrives as an empty object, and the workflow rejects it against whatever its own
1738
+ * schema says the property should be. Measured in production as
1739
+ * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
1740
+ * about a type, on a form where the user had picked a perfectly good file.
1960
1741
  *
1961
- * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
1962
- * between digests and may live a month, where closing the browser on Tuesday and
1963
- * coming back on Friday to press Stop is the ordinary case rather than an edge
1964
- * one, and a tab-scoped key would answer that with an empty form beside a run
1965
- * still posting somewhere. It is as far as a key can go without a login, and no
1966
- * further. `podcast-digest` is that template; the other five ship the default.
1742
+ * Exported beside {@link filesOf} because it is the same question asked at the
1743
+ * other end: that one decides which fields to UPLOAD, this one checks that none
1744
+ * were missed. Both hooks are the callers.
1745
+ */
1746
+ function fileFields(input) {
1747
+ if (!isRecord(input)) return [];
1748
+ return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
1749
+ }
1750
+ //#endregion
1751
+ //#region src/_upload-files.ts
1752
+ /**
1753
+ * Turning a form's `File`s into stored upload ids, pauses and all.
1967
1754
  *
1968
- * ## Anything ELSE a page stores back must be VALIDATED on read
1755
+ * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
1756
+ * real one: that module is the two HOOKS and the state between them, where this
1757
+ * is the walk over a submitted input — which is the only part of it that knows
1758
+ * what a `File` is, holds a loop, and survives being re-entered.
1969
1759
  *
1970
- * This key needs no validation, and it is worth saying why, because it is the
1971
- * exception: any string is a legal key, so a value from storage can only fail to
1972
- * match a run. A page that remembers something more — which MODE submitted, say
1973
- * — is remembering a value it will turn into a name, and storage hands back a
1974
- * string some earlier version of that page wrote: a renamed mode, a hand-edited
1975
- * value, a slot another app on the origin happens to share. Unchecked, that
1976
- * starts a run called `undefined` and answers a 400 nobody typed. Check it
1977
- * against the page's own list on the way out (`recalledMode` in
1978
- * `transcription-workflow/recover.ts` is the worked example) — the recall is the
1979
- * page's, the validation is not optional.
1760
+ * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
1761
+ * one file rather than walking an input and shares only the gate underneath both
1762
+ * (`_upload-session.ts`).
1763
+ */
1764
+ /** A fresh session for one submission of `workflow`. */
1765
+ function createUploadSession(workflow) {
1766
+ return {
1767
+ scope: workflow,
1768
+ ids: /* @__PURE__ */ new Map(),
1769
+ stored: /* @__PURE__ */ new Map(),
1770
+ tried: /* @__PURE__ */ new Set(),
1771
+ gate: createUploadGate()
1772
+ };
1773
+ }
1774
+ /**
1775
+ * The id to store this file under: the one a previous page load was using, or a
1776
+ * fresh one.
1980
1777
  *
1981
- * ## The slot is keyed by the page's own URL
1778
+ * **The AGENT decides, never the fingerprint.** Two files can agree on every
1779
+ * field `_upload-recall.ts` keys by, so the recalled id is a candidate that has to
1780
+ * be checked before a byte is sent to it — and the check is cheap and exact,
1781
+ * because `uploadInfo` is the same record a resume already reads.
1982
1782
  *
1983
- * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
1984
- * would have two agents scaffolded from the same template recover each other's
1985
- * runs. The key is the page's own directory — resolved through `"./"`, which
1986
- * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
1987
- * find the same key. Same call `session-resume-store.ts` makes, for the same
1988
- * reason.
1783
+ * Three answers, and the third is why this is not just a storage lookup:
1989
1784
  *
1990
- * One key per PAGE is right even for a page driving several workflows: `find` is
1991
- * scoped by workflow as well as by key, so three hooks sharing one key recover
1992
- * three separate runs. `transcription-workflow` is that page.
1993
- *
1994
- * Every access is guarded. Storage THROWS outright in some contexts (Safari
1995
- * private mode, an iframe blocked by policy) and is ABSENT in others (any
1996
- * server-side render), and a page that cannot remember its key must degrade to
1997
- * the behaviour it would have had anyway — one run per load — rather than
1998
- * failing to render.
1785
+ * - **Complete.** Every byte is in from a load that is gone, so there is nothing
1786
+ * to send: the caller takes the id and starts the run. This is the refresh that
1787
+ * costs one `GET` instead of a second 200 MB upload.
1788
+ * - **Unfinished, with windows.** `UploadInfo.ranges` is what makes an
1789
+ * upload resumable at all, so the id is reused and the attempt claims it.
1790
+ * - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
1791
+ * upload reporting NO windows — which is a partial single `PUT`, and a second
1792
+ * `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
1793
+ * it would turn a reload into a failure the person cannot clear, so the entry is
1794
+ * dropped and the file gets a fresh id.
1999
1795
  */
2000
- /** Where a run key lives, namespaced like this package's two other stores. */
2001
- const PREFIX = "aai:run-key:";
2002
- /** This page's own slot — see "The slot is keyed by the page's own URL". */
2003
- function slotFor() {
2004
- const href = globalThis.location?.href;
2005
- if (href === void 0) return PREFIX;
2006
- try {
2007
- return `${PREFIX}${new URL("./", href).href}`;
2008
- } catch {
2009
- return `${PREFIX}${href}`;
1796
+ async function claimId(api, session, file) {
1797
+ const remembered = recallUploadId(session.scope, file);
1798
+ if (remembered === void 0) return {
1799
+ id: randomUploadId(),
1800
+ complete: false
1801
+ };
1802
+ const info = await api.uploadInfo(remembered).catch(() => void 0);
1803
+ if (info?.complete === true) return {
1804
+ id: remembered,
1805
+ complete: true
1806
+ };
1807
+ if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
1808
+ session.tried.add(file);
1809
+ return {
1810
+ id: remembered,
1811
+ complete: false
1812
+ };
2010
1813
  }
1814
+ forgetUploadId(session.scope, file);
1815
+ return {
1816
+ id: randomUploadId(),
1817
+ complete: false
1818
+ };
2011
1819
  }
2012
1820
  /**
2013
- * Read the key this page already has, or mint and remember one.
1821
+ * Replace every `File` in a submitted form with the id of a stored upload,
1822
+ * reporting how far each one has got.
2014
1823
  *
2015
- * Not exported: a page that wants a key wants it for the life of a component,
2016
- * which is what the hook is. Calling this per render would mint a fresh key and
2017
- * hand `recover` one nothing was ever started under.
1824
+ * Sequential rather than `Promise.all`: these are large bodies, and a form with
1825
+ * two 200 MB recordings should send them one after another rather than compete
1826
+ * for the same connection. That is also what makes a single bar honest — one
1827
+ * file is in flight at a time, and `index`/`count` say which.
1828
+ *
1829
+ * Anything that is not a `File` (or an array of them) passes through untouched,
1830
+ * so this is invisible to every form that has none — including one whose values
1831
+ * are not an object at all, which `submit` accepts.
1832
+ *
1833
+ * ## `uploadStream`, not `upload`, and the id is the reason
1834
+ *
1835
+ * The difference between the two calls is only who mints the id — and that is
1836
+ * exactly what decides whether an interrupted upload can be picked up again. An
1837
+ * `upload` mints its own at the END and hands it back, so a caller whose upload
1838
+ * died has nothing to name what was stored and no choice but to send the file
1839
+ * again. A `uploadStream` is told the id up front, so the windows already in the
1840
+ * store are addressable, which is what both a pause and a server restart need.
1841
+ *
1842
+ * Nothing else about the submission changes: the run is still started after the
1843
+ * last byte lands, so the incomplete record a streamed upload leaves along the
1844
+ * way is one nobody reads.
2018
1845
  */
2019
- function mintRunKey(storage) {
2020
- try {
2021
- const store = storage === "local" ? globalThis.localStorage : globalThis.sessionStorage;
2022
- const slot = slotFor();
2023
- const stored = store?.getItem(slot);
2024
- if (stored !== null && stored !== void 0) return stored;
2025
- const minted = crypto.randomUUID();
2026
- store?.setItem(slot, minted);
2027
- return minted;
2028
- } catch {
2029
- return crypto.randomUUID();
1846
+ async function uploadFiles(api, input, report, parallel, session) {
1847
+ if (!isRecord(input)) return input;
1848
+ const entries = Object.entries(input);
1849
+ const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
1850
+ let index = 0;
1851
+ const store = async (file) => {
1852
+ index += 1;
1853
+ const done = session.stored.get(file);
1854
+ if (done !== void 0) return done;
1855
+ const position = {
1856
+ name: file.name,
1857
+ index,
1858
+ count
1859
+ };
1860
+ const known = session.ids.get(file);
1861
+ const claimed = known === void 0 ? await claimId(api, session, file) : {
1862
+ id: known,
1863
+ complete: false
1864
+ };
1865
+ const id = claimed.id;
1866
+ if (known === void 0) {
1867
+ session.ids.set(file, id);
1868
+ rememberUploadId(session.scope, file, id);
1869
+ }
1870
+ if (claimed.complete) {
1871
+ report({
1872
+ ...position,
1873
+ loaded: file.size,
1874
+ total: file.size,
1875
+ fraction: 1,
1876
+ paused: false
1877
+ });
1878
+ session.stored.set(file, id);
1879
+ return id;
1880
+ }
1881
+ await sendThroughGate(session.gate, async (resume) => {
1882
+ await api.uploadStream(id, file, {
1883
+ name: file.name,
1884
+ signal: session.gate.signal,
1885
+ onProgress: (progress) => report({
1886
+ ...position,
1887
+ ...progress,
1888
+ paused: session.gate.paused
1889
+ }),
1890
+ ...omitUndefined({
1891
+ parallel,
1892
+ resume: resume || session.tried.has(file) ? true : void 0
1893
+ })
1894
+ });
1895
+ });
1896
+ session.stored.set(file, id);
1897
+ return id;
1898
+ };
1899
+ const out = {};
1900
+ for (const [name, value] of entries) {
1901
+ if (value instanceof File) {
1902
+ out[name] = await store(value);
1903
+ continue;
1904
+ }
1905
+ const chosen = filesOf(value);
1906
+ if (chosen.length === 0) {
1907
+ out[name] = value;
1908
+ continue;
1909
+ }
1910
+ const ids = [];
1911
+ for (const file of chosen) ids.push(await store(file));
1912
+ out[name] = ids;
2030
1913
  }
1914
+ return out;
2031
1915
  }
1916
+ //#endregion
1917
+ //#region src/_upload-pause.ts
2032
1918
  /**
2033
- * A lookup key for `useWorkflowSubmit({ key, recover: true })`, stable across
2034
- * reloads.
1919
+ * The two buttons a page puts over a live upload, bound to whichever gate the
1920
+ * submission is currently holding.
2035
1921
  *
2036
- * @param options - See the module doc for the whole argument. The storage kind
2037
- * is read once, when the key is minted: a value that changed afterwards would
2038
- * be asking to move a key that has already been recorded with a run.
2039
- * @returns The key to record runs under and to look them up by — the same one
2040
- * for the life of the component, and for the next load in the same tab (or the
2041
- * same browser, under `"local"`).
1922
+ * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
1923
+ * both reported the pause the same way: park the gate, then fold `paused` into
1924
+ * the status the bar is already drawing. Folded rather than replaced, because
1925
+ * everything else about that status — which file, how far, of how many — is
1926
+ * still true. Two copies of that rule are two copies that can stop agreeing
1927
+ * about what a paused bar says.
2042
1928
  */
2043
- function useRunKey(options = {}) {
2044
- const { storage = "session" } = options;
2045
- const [key] = useState(() => mintRunKey(storage));
2046
- return key;
1929
+ /**
1930
+ * Bind pause/resume to the live submission's gate.
1931
+ *
1932
+ * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
1933
+ * stable across submissions rather than being re-created per gate.
1934
+ * @param setUpload - The status setter the progress bar renders from.
1935
+ *
1936
+ * @internal
1937
+ */
1938
+ function useUploadPause(getGate, setUpload) {
1939
+ const setPaused = useCallback((paused) => {
1940
+ setUpload((current) => current ? {
1941
+ ...current,
1942
+ paused
1943
+ } : current);
1944
+ }, [setUpload]);
1945
+ return {
1946
+ pauseUpload: useCallback(() => {
1947
+ getGate()?.pause();
1948
+ setPaused(true);
1949
+ }, [getGate, setPaused]),
1950
+ resumeUpload: useCallback(() => {
1951
+ getGate()?.resume();
1952
+ setPaused(false);
1953
+ }, [getGate, setPaused])
1954
+ };
1955
+ }
1956
+ //#endregion
1957
+ //#region src/use-workflow-form.ts
1958
+ /**
1959
+ * The hook a FORM needs, as against the one a status view does.
1960
+ *
1961
+ * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
1962
+ * This is what comes before it: `useWorkflowSubmit` starts a run and hands the
1963
+ * id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
1964
+ * `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
1965
+ *
1966
+ * ## `useWorkflowSubmit` — a form's two halves in one hook
1967
+ *
1968
+ * A page that submits a workflow always needs the same four pieces of state:
1969
+ * the run id, whether a submit is in flight, whether the RUN is still going, and
1970
+ * whichever of the two failed. `link-digest` writes them out by hand, which is
1971
+ * the right shape for a template teaching the primitives and the wrong shape to
1972
+ * write a third time — and it is easy to get subtly wrong: dropping the previous
1973
+ * run id before the new `POST` returns is what stops a finished result sitting
1974
+ * under a form that is already submitting again.
1975
+ *
1976
+ * So this is `api.start` plus {@link useWorkflowRun}, with the state between
1977
+ * them. It adds no transport of its own and holds no run state of its own; the
1978
+ * watching (stream first, poll as its fallback, terminal stops) is entirely
1979
+ * `useWorkflowRun`'s, and `run` here IS its run.
1980
+ *
1981
+ * ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
1982
+ *
1983
+ * `api.startAndWait` would collapse this to one request, and it is the wrong
1984
+ * default for a page: it holds a socket open for up to a minute, answers nothing
1985
+ * until it settles, and a page has `useWorkflowRun` — which survives a reload,
1986
+ * shows progress, and costs one stream. The synchronous call is for callers with
1987
+ * nowhere to put a watch (a script, a cron, a form POST from a server). Pass
1988
+ * `wait` here when the page really does want one request, and the run is
1989
+ * followed from the same id either way.
1990
+ */
1991
+ /**
1992
+ * Start a workflow from a form, and follow the run it creates.
1993
+ *
1994
+ * @typeParam D - The workflow DEFINITION, which types both halves of the
1995
+ * submission: `submit(input)` takes what the workflow's schema parses to, and
1996
+ * `run.status === "completed"` narrows to a typed `run.output`.
1997
+ *
1998
+ * It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
1999
+ * already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
2000
+ * `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
2001
+ * 400 in the browser. Naming the def instead types the input from the same
2002
+ * declaration — and `import type` is ERASED, so it costs the bundle nothing.
2003
+ * Passing an output type where a def belongs is now a compile error rather
2004
+ * than a silent loss of typing, which is the point.
2005
+ *
2006
+ * @example
2007
+ * ```tsx no-check
2008
+ * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
2009
+ * import type { digest } from "./agent.ts";
2010
+ *
2011
+ * function DigestForm() {
2012
+ * const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
2013
+ * return (
2014
+ * <Form onSubmit={(values) => submit(values)} error={error}>
2015
+ * <TextField name="url" label="Link" type="url" required />
2016
+ * <SubmitButton pending={pending}>Digest</SubmitButton>
2017
+ * {run?.status === "completed" && <p>{run.output.title}</p>}
2018
+ * </Form>
2019
+ * );
2020
+ * }
2021
+ * ```
2022
+ *
2023
+ * @public
2024
+ */
2025
+ function useWorkflowSubmit(workflow, opts = {}) {
2026
+ const { api, recover = true, wait, intervalMs, parallel } = opts;
2027
+ const key = useDefaultRunKey(opts.key);
2028
+ const [runId, setRunId] = useState(void 0);
2029
+ const [starting, setStarting] = useState(false);
2030
+ const [startError, setStartError] = useState(void 0);
2031
+ const [upload, setUpload] = useState(void 0);
2032
+ const session = useRef(void 0);
2033
+ const getClient = useWorkflowApiRef(api);
2034
+ const tracked = useWorkflowRun(runId, omitUndefined({
2035
+ api,
2036
+ intervalMs
2037
+ }));
2038
+ const { wake, cancel } = useRunControls(runId, getClient);
2039
+ const recovering = useRecoveredRun({
2040
+ workflow,
2041
+ key,
2042
+ enabled: recover,
2043
+ getClient,
2044
+ onFound: (found) => {
2045
+ setRunId((current) => current ?? found);
2046
+ },
2047
+ onError: setStartError
2048
+ });
2049
+ const submit = useCallback(async (input) => {
2050
+ const client = getClient();
2051
+ setStarting(true);
2052
+ setStartError(void 0);
2053
+ setRunId(void 0);
2054
+ session.current?.gate.cancel();
2055
+ const current = createUploadSession(workflow);
2056
+ session.current = current;
2057
+ try {
2058
+ const options = omitUndefined({ key });
2059
+ const started = await uploadFiles(client, input, setUpload, parallel, current);
2060
+ setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
2061
+ ...options,
2062
+ wait
2063
+ })).runId);
2064
+ } catch (err) {
2065
+ if (!current.gate.cancelled) setStartError(errorMessage(err));
2066
+ } finally {
2067
+ if (session.current === current) {
2068
+ session.current = void 0;
2069
+ setStarting(false);
2070
+ setUpload(void 0);
2071
+ }
2072
+ }
2073
+ }, [
2074
+ workflow,
2075
+ key,
2076
+ wait,
2077
+ parallel,
2078
+ getClient
2079
+ ]);
2080
+ const reset = useCallback(() => {
2081
+ session.current?.gate.cancel();
2082
+ session.current = void 0;
2083
+ setRunId(void 0);
2084
+ setStartError(void 0);
2085
+ setUpload(void 0);
2086
+ }, []);
2087
+ const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => session.current?.gate, []), setUpload);
2088
+ return {
2089
+ submit,
2090
+ submitForm: submit,
2091
+ reset,
2092
+ wake,
2093
+ cancel,
2094
+ pauseUpload,
2095
+ resumeUpload,
2096
+ run: tracked.run,
2097
+ pending: recovering || starting || tracked.polling,
2098
+ upload,
2099
+ error: startError ?? tracked.error
2100
+ };
2047
2101
  }
2048
2102
  //#endregion
2049
- //#region use-workflow-runs.ts
2103
+ //#region src/use-workflow-runs.ts
2050
2104
  /**
2051
2105
  * The RUNS a workflow has had — the list a page shows beside its form.
2052
2106
  *
@@ -2144,7 +2198,7 @@ function useWorkflowRuns(workflow, opts = {}) {
2144
2198
  };
2145
2199
  }
2146
2200
  //#endregion
2147
- //#region use-workflow-stream.ts
2201
+ //#region src/use-workflow-stream.ts
2148
2202
  /**
2149
2203
  * Starting a run BEFORE its file has finished uploading.
2150
2204
  *
@@ -2423,7 +2477,7 @@ function fileAt(input, field) {
2423
2477
  return filesOf(input[field])[0];
2424
2478
  }
2425
2479
  //#endregion
2426
- //#region workflow-status-labels.ts
2480
+ //#region src/workflow-status-labels.ts
2427
2481
  /**
2428
2482
  * The default status line per {@link WorkflowRunStatus}.
2429
2483
  *