@alexkroman1/aai-ui 13.2.0 → 14.0.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 (83) hide show
  1. package/README.md +159 -69
  2. package/dist/{_colors-j8XMToi9.js → _colors-CpZO-88A.js} +25 -2
  3. package/dist/{_module-url-C_4gRVL0.js → _module-url-C13kAJ87.js} +1 -1
  4. package/dist/_recover-run.d.ts +13 -7
  5. package/dist/_submission-state.d.ts +92 -0
  6. package/dist/_upload-files.d.ts +2 -2
  7. package/dist/_upload-report.d.ts +26 -0
  8. package/dist/{_utils-B6498_bm.js → _utils-DnQDM9Uy.js} +4 -4
  9. package/dist/_utils.d.ts +3 -3
  10. package/dist/_web-storage.d.ts +43 -0
  11. package/dist/_workflow-files.d.ts +1 -1
  12. package/dist/{aai-logo-CXZGPSIY.js → aai-logo-CFlomZlS.js} +1 -1
  13. package/dist/agent-state-labels.d.ts +60 -0
  14. package/dist/audio.js +21 -15
  15. package/dist/{chat-view-DDTtrh7N.js → chat-view-C_3T7Ln8.js} +100 -33
  16. package/dist/{client-config-BT_kWID5.js → client-config-DJQHnYjm.js} +5 -5
  17. package/dist/client-config.d.ts +4 -4
  18. package/dist/client-dir.d.ts +1 -1
  19. package/dist/client-dir.js +2 -2
  20. package/dist/components/_colors.d.ts +23 -0
  21. package/dist/components/_form-readiness.d.ts +1 -1
  22. package/dist/components/bullet-list.d.ts +74 -0
  23. package/dist/components/button.js +4 -4
  24. package/dist/components/chat-view.js +1 -1
  25. package/dist/components/console-shell.d.ts +16 -20
  26. package/dist/components/controls.js +4 -4
  27. package/dist/components/facts.d.ts +81 -0
  28. package/dist/components/form-fields.d.ts +6 -6
  29. package/dist/components/form-types.d.ts +1 -1
  30. package/dist/components/form.d.ts +1 -1
  31. package/dist/components/message-list.js +1 -1
  32. package/dist/components/session-error-banner.d.ts +69 -0
  33. package/dist/components/sidebar-layout.js +1 -1
  34. package/dist/components/start-screen.js +4 -4
  35. package/dist/components/tool-call-block.js +1 -1
  36. package/dist/components/tool-config-context.d.ts +1 -1
  37. package/dist/components/workflow-progress.d.ts +11 -4
  38. package/dist/context.d.ts +142 -19
  39. package/dist/context.js +156 -18
  40. package/dist/default-client/assets/{audio-BuDICbPf.js → audio-9zQsNc1w.js} +1 -1
  41. package/dist/default-client/assets/index-BTv30Z4F.css +2 -0
  42. package/dist/default-client/assets/index-RAZ-29Sz.js +284 -0
  43. package/dist/default-client/index.html +2 -2
  44. package/dist/default-client.d.ts +1 -1
  45. package/dist/define-client.d.ts +19 -19
  46. package/dist/define-client.js +20 -20
  47. package/dist/{eyebrow-C6ZFuiz6.js → eyebrow-UfmSz9yy.js} +1 -1
  48. package/dist/hooks.d.ts +44 -8
  49. package/dist/hooks.js +20 -14
  50. package/dist/index.d.ts +13 -8
  51. package/dist/index.js +1447 -1160
  52. package/dist/internal.d.ts +2 -2
  53. package/dist/internal.js +5 -5
  54. package/dist/{message-list-BJYyuIcR.js → message-list-CdOnSh5m.js} +23 -16
  55. package/dist/page.d.ts +11 -11
  56. package/dist/session-core-audio-setup.d.ts +1 -1
  57. package/dist/session-core-dial.d.ts +0 -2
  58. package/dist/{session-core-DxBYsfHA.js → session-core-gwePM95B.js} +135 -62
  59. package/dist/session-core-messages.d.ts +2 -2
  60. package/dist/session-core-types.d.ts +58 -1
  61. package/dist/session-core.d.ts +6 -6
  62. package/dist/session-core.js +2 -2
  63. package/dist/session-resume-store.d.ts +3 -3
  64. package/dist/{tool-call-block-tcPQAkcP.js → tool-call-block-C2t_5fpp.js} +30 -12
  65. package/dist/{tool-config-context-DzAofqi_.js → tool-config-context-Es4YUzV2.js} +2 -2
  66. package/dist/types.d.ts +19 -4
  67. package/dist/types.js +3 -3
  68. package/dist/{url-chips-YqhCjWfQ.js → url-chips-BxhzZgk2.js} +5 -5
  69. package/dist/use-conversation.d.ts +1 -1
  70. package/dist/use-run-key.d.ts +44 -10
  71. package/dist/{use-user-transcript-C14qWFu2.js → use-user-transcript-uyHhzy4d.js} +4 -3
  72. package/dist/use-workflow-form.d.ts +64 -91
  73. package/dist/{use-workflow-progress-Cu0SxMyg.js → use-workflow-run-CP2ekKPV.js} +254 -257
  74. package/dist/use-workflow-stream.d.ts +5 -2
  75. package/dist/use-workflows.d.ts +77 -0
  76. package/dist/workflow-client.d.ts +1 -1
  77. package/dist/worklets/capture-processor.js +2 -2
  78. package/dist/worklets/playback-processor.js +2 -2
  79. package/package.json +6 -6
  80. package/styles.css +78 -0
  81. package/dist/default-client/assets/index-B1_ROnTJ.js +0 -284
  82. package/dist/default-client/assets/index-S5fkKi6B.css +0 -2
  83. package/dist/tsdown.config.d.ts +0 -2
package/dist/index.js CHANGED
@@ -1,26 +1,190 @@
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";
3
- import { ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
4
- import { r as inkTint } from "./_colors-j8XMToi9.js";
1
+ import { n as fetchClientConfig } from "./client-config-DJQHnYjm.js";
2
+ import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-CdOnSh5m.js";
3
+ import { ThemeProvider, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
4
+ import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.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, r as SessionErrorBanner, t as ChatView } from "./chat-view-C_3T7Ln8.js";
7
+ import { n as setPageTitle } from "./_utils-DnQDM9Uy.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-uyHhzy4d.js";
10
+ import { n as ToolCallRow } from "./tool-call-block-C2t_5fpp.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";
15
- import { client, mountRoot, resolveContainer } from "./define-client.js";
13
+ import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CP2ekKPV.js";
14
+ import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-gwePM95B.js";
15
+ import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
16
16
  import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
17
17
  import clsx from "clsx";
18
18
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
19
- import { createContext, createElement, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
19
+ import { createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
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/agent-state-labels.ts
24
+ /**
25
+ * The default label per {@link AgentState}.
26
+ *
27
+ * Override the ones your page has a better word for and keep the rest:
28
+ *
29
+ * ```ts
30
+ * import type { AgentState } from "@alexkroman1/aai-ui";
31
+ * import { AGENT_STATE_LABELS } from "@alexkroman1/aai-ui";
32
+ *
33
+ * // A dispatch board that shouts, and renames one state.
34
+ * const STATE_LABEL = { ...AGENT_STATE_LABELS, thinking: "Processing" };
35
+ * const shout = (s: AgentState) => STATE_LABEL[s].toUpperCase();
36
+ * ```
37
+ *
38
+ * **Sentence case, deliberately.** A template that wants caps applies its own
39
+ * `.toUpperCase()`, and a template that wants Title Case is already there;
40
+ * shipping the shouted form instead would leave the two chromes that do not
41
+ * shout with a string they have to un-shout, which no case transform does
42
+ * correctly.
43
+ *
44
+ * Two wordings are decisions rather than transliterations of the member name:
45
+ *
46
+ * - `disconnected` is **"Idle"**. It is the state a session is in BEFORE it has
47
+ * ever started as well as after it ends, so it is the first word most callers
48
+ * see; "Disconnected" reads as a fault on a page where nothing has gone
49
+ * wrong yet. Both chromes that mapped this state by hand chose "Idle" too.
50
+ * - `connecting` and `thinking` carry an ellipsis, `listening` and `speaking`
51
+ * do not. The first two are waits with nothing for the caller to do; the
52
+ * other two describe someone actually talking. Same distinction
53
+ * `WORKFLOW_STATUS_LABELS` draws with its one "Working…".
54
+ *
55
+ * @public
56
+ */
57
+ const AGENT_STATE_LABELS = {
58
+ disconnected: "Idle",
59
+ connecting: "Connecting…",
60
+ ready: "Ready",
61
+ listening: "Listening",
62
+ thinking: "Thinking…",
63
+ speaking: "Speaking",
64
+ error: "Error"
65
+ };
66
+ //#endregion
67
+ //#region src/components/bullet-list.tsx
68
+ /** @jsxImportSource react */
69
+ /**
70
+ * A disc-bulleted list of short strings — a run's key points, findings, risks.
71
+ *
72
+ * Five pages had written this, byte-identical apart from a `text-sm` suffix on
73
+ * two of them, and all five had the same two defects. Both are the reason this
74
+ * is a component rather than four lines a page repeats:
75
+ *
76
+ * - **All five keyed by the string itself, and these lists are MODEL OUTPUT.**
77
+ * Two identical bullets are entirely plausible — a summariser that repeats
78
+ * itself is a bad summary, not a bad program — and a repeated string is then
79
+ * a duplicate `key`: React warns, and the two `<li>`s contend for one slot in
80
+ * the reconciliation. What is keyed here instead is the content PLUS how many
81
+ * times that content has already appeared in this list, which is unique by
82
+ * construction and unchanged by a re-render that did not change the text.
83
+ * (Position alone would also be sound — these lists are replaced wholesale by
84
+ * each new output and never reordered — but it is what `noArrayIndexKey`
85
+ * exists to talk you out of, and a unique key is one `Map` away, so there is
86
+ * no reason to spend a lint suppression on it.)
87
+ * - **Three of the five rendered an empty `<ul>` under a heading.** Two had
88
+ * hand-rolled `if (items.length === 0) return null` and three had not, so the
89
+ * same absent field was "nothing" on two pages and a stray heading with a
90
+ * void under it on three. Emptiness renders NOTHING here, `title` included:
91
+ * a heading over no bullets is a claim the run did not make.
92
+ *
93
+ * @example
94
+ * ```tsx
95
+ * import { BulletList } from "@alexkroman1/aai-ui";
96
+ *
97
+ * function Findings({ risks }: { risks: string[] }) {
98
+ * return <BulletList title="Risks" items={risks} size="sm" />;
99
+ * }
100
+ * ```
101
+ *
102
+ * @param props - Bullet-list props.
103
+ *
104
+ * @public
105
+ */
106
+ function BulletList({ items, title, size = "base", className }) {
107
+ if (items.length === 0) return null;
108
+ const seen = /* @__PURE__ */ new Map();
109
+ const list = /* @__PURE__ */ jsx("ul", {
110
+ className: clsx("flex list-disc flex-col gap-1 pl-5", size === "sm" && "text-sm", className),
111
+ children: items.map((item) => {
112
+ const nth = seen.get(item) ?? 0;
113
+ seen.set(item, nth + 1);
114
+ return /* @__PURE__ */ jsx("li", { children: item }, `${nth}:${item}`);
115
+ })
116
+ });
117
+ const heading = title === void 0 || title === null || title === false ? void 0 : title;
118
+ if (heading === void 0) return list;
119
+ return /* @__PURE__ */ jsxs("section", {
120
+ className: "flex flex-col gap-1",
121
+ children: [/* @__PURE__ */ jsx("h3", {
122
+ className: "text-sm font-medium opacity-70",
123
+ children: heading
124
+ }), list]
125
+ });
126
+ }
127
+ //#endregion
128
+ //#region src/components/facts.tsx
129
+ /** @jsxImportSource react */
130
+ /** What goes between two facts. A separator, not a bullet — U+00B7, not U+2022. */
131
+ const FACT_SEPARATOR = " · ";
132
+ /**
133
+ * A muted line of run facts, joined by `·` — "6 segments · 12:04 of audio ·
134
+ * 1,840 words".
135
+ *
136
+ * Nine pages had written this by hand under four different typographies for
137
+ * one role, two of them (`call-audit` and `spoken-summary`) byte-identical down
138
+ * to the payload. Three things it takes off the caller:
139
+ *
140
+ * - **The separator cannot be forgotten, and neither can the space around it.**
141
+ * Four of the nine carried a literal `{" "}` at the end of a line, because
142
+ * Prettier's wrap ate the space that made `· ` read as a separator rather
143
+ * than as punctuation glued to the next word. A line that is correct only
144
+ * because somebody remembered an invisible JSX expression is exactly the
145
+ * thing a component should own.
146
+ * - **A fact worth omitting is omitted, and by the caller's own condition.**
147
+ * The hand-written shape for a conditional fact was to splice the separator
148
+ * into the string — `{x ? \` · budget exhausted\` : ""}` — which puts the
149
+ * punctuation in two places and gets the leading separator wrong the moment
150
+ * the fact before it also disappears. Passing the condition and letting this
151
+ * drop it keeps the separator in one place.
152
+ * - **A line with nothing left to say renders NOTHING.** With every fact
153
+ * conditional, the alternative is a muted empty row, or a bare `·`.
154
+ *
155
+ * The facts are JOINED into one string rather than interleaved as elements, and
156
+ * that is why the prop is text: joined, there is no per-fact `key` to invent —
157
+ * the same reasoning `WorkflowProgress` gives for its log lines. A line that
158
+ * genuinely needs an element in it (a link) wants its own markup.
159
+ *
160
+ * @example
161
+ * ```tsx
162
+ * import { Facts } from "@alexkroman1/aai-ui";
163
+ *
164
+ * function RunFacts({ words, cut }: { words: number; cut: number }) {
165
+ * return <Facts size="xs" items={[`${words} words`, cut > 0 && `${cut} blind cuts`]} />;
166
+ * }
167
+ * ```
168
+ *
169
+ * @param props - Facts-line props.
170
+ *
171
+ * @public
172
+ */
173
+ function Facts({ items, size = "sm", as = "p", className }) {
174
+ const shown = items.filter((item) => item !== false && item !== null && item !== void 0 && item !== "");
175
+ if (shown.length === 0) return null;
176
+ const text = shown.join(FACT_SEPARATOR);
177
+ const classes = clsx(size === "xs" ? "text-xs opacity-60" : "text-sm opacity-70", className);
178
+ return as === "span" ? /* @__PURE__ */ jsx("span", {
179
+ className: classes,
180
+ children: text
181
+ }) : /* @__PURE__ */ jsx("p", {
182
+ className: classes,
183
+ children: text
184
+ });
185
+ }
186
+ //#endregion
187
+ //#region src/components/_form-readiness.ts
24
188
  /**
25
189
  * Whether a form's own fields exist yet.
26
190
  *
@@ -47,7 +211,7 @@ import { createEpoch } from "@alexkroman1/aai/internal";
47
211
  * one describes an element that EXISTS. So this is a context rather than an
48
212
  * attribute, and it carries the one fact a DOM read cannot recover.
49
213
  *
50
- * A form with no such children is ready by definition — `useFormFieldsPending`
214
+ * A form with no such children is ready by definition — `useFormReadiness`
51
215
  * outside a provider reports nothing pending, so every hand-written form is
52
216
  * unaffected and `Form` keeps working outside this package.
53
217
  */
@@ -106,7 +270,7 @@ function useDeclareFieldsPending(pending) {
106
270
  ]);
107
271
  }
108
272
  //#endregion
109
- //#region components/_form-values.ts
273
+ //#region src/components/_form-values.ts
110
274
  /**
111
275
  * Read one `<form>`'s named controls into a plain object.
112
276
  *
@@ -173,7 +337,7 @@ function readFiles(files, read) {
173
337
  if (read === "upload") return Promise.resolve([...files]);
174
338
  return Promise.all(files.map((file) => describeFile(file, read)));
175
339
  }
176
- /** The `data-aai-read` attribute as a {@link FileRead}, defaulting to `"none"`. */
340
+ /** The `data-aai-read` attribute as a {@link FileReadMode}, defaulting to `"none"`. */
177
341
  function readMode(raw) {
178
342
  return raw === "text" || raw === "dataUrl" || raw === "upload" ? raw : "none";
179
343
  }
@@ -201,7 +365,7 @@ function dataUrl(file) {
201
365
  });
202
366
  }
203
367
  //#endregion
204
- //#region components/form-fields.tsx
368
+ //#region src/components/form-fields.tsx
205
369
  /** @jsxImportSource react */
206
370
  /**
207
371
  * The controls a form is made of — the shell, the six fields, and the styling
@@ -274,12 +438,12 @@ function Field({ label, hint, htmlFor, className, children }) {
274
438
  function useControlProps() {
275
439
  const theme = useTheme();
276
440
  return {
277
- className: clsx("w-full rounded-aai border px-3 py-2 text-sm font-aai", "outline-none focus-visible:[outline:2px_solid] focus-visible:[outline-offset:2px]", "disabled:cursor-not-allowed disabled:opacity-50"),
441
+ className: clsx("w-full rounded-aai border px-3 py-2 text-sm font-aai", FOCUS_RING, "disabled:cursor-not-allowed disabled:opacity-50"),
278
442
  style: {
279
443
  background: theme.surface,
280
444
  color: theme.text,
281
445
  borderColor: theme.border,
282
- outlineColor: theme.primary
446
+ ...focusRingStyle(theme.primary)
283
447
  }
284
448
  };
285
449
  }
@@ -311,16 +475,14 @@ function useFileControlProps() {
311
475
  };
312
476
  }
313
477
  /**
314
- * A single-line text input.
315
- *
316
- * Accepts every `<input>` attribute except `name` and `className`, which this
317
- * component owns, plus the shared {@link FieldShell} props.
318
- *
319
- * @param props - {@link FieldShell} props plus `<input>` attributes.
478
+ * `TextField` and `NumberField`, which differ only in the `type` they set.
320
479
  *
321
- * @public
480
+ * Internal, because the two stay separate PUBLIC exports: each is named in the
481
+ * `components` capability contract and in the API report, and a caller writes
482
+ * the one whose value shape it wants — `NumberField` is the one that
483
+ * contributes a number to {@link FormValues}.
322
484
  */
323
- function TextField({ name, label, hint, className, ...rest }) {
485
+ function InputField({ defaultType, name, label, hint, className, ...rest }) {
324
486
  const id = useId();
325
487
  const control = useControlProps();
326
488
  return /* @__PURE__ */ jsx(Field, {
@@ -331,13 +493,29 @@ function TextField({ name, label, hint, className, ...rest }) {
331
493
  children: /* @__PURE__ */ jsx("input", {
332
494
  id,
333
495
  name,
334
- type: "text",
496
+ type: defaultType,
335
497
  ...control,
336
498
  ...rest
337
499
  })
338
500
  });
339
501
  }
340
502
  /**
503
+ * A single-line text input.
504
+ *
505
+ * Accepts every `<input>` attribute except `name` and `className`, which this
506
+ * component owns, plus the shared {@link FieldShell} props.
507
+ *
508
+ * @param props - {@link FieldShell} props plus `<input>` attributes.
509
+ *
510
+ * @public
511
+ */
512
+ function TextField(props) {
513
+ return /* @__PURE__ */ jsx(InputField, {
514
+ defaultType: "text",
515
+ ...props
516
+ });
517
+ }
518
+ /**
341
519
  * A number input. Contributes a NUMBER to {@link FormValues}, or nothing when
342
520
  * left empty.
343
521
  *
@@ -349,21 +527,10 @@ function TextField({ name, label, hint, className, ...rest }) {
349
527
  *
350
528
  * @public
351
529
  */
352
- function NumberField({ name, label, hint, className, ...rest }) {
353
- const id = useId();
354
- const control = useControlProps();
355
- return /* @__PURE__ */ jsx(Field, {
356
- label,
357
- hint,
358
- htmlFor: id,
359
- className,
360
- children: /* @__PURE__ */ jsx("input", {
361
- id,
362
- name,
363
- type: "number",
364
- ...control,
365
- ...rest
366
- })
530
+ function NumberField(props) {
531
+ return /* @__PURE__ */ jsx(InputField, {
532
+ defaultType: "number",
533
+ ...props
367
534
  });
368
535
  }
369
536
  /**
@@ -474,14 +641,14 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
474
641
  * travel in it. With `upload` the field contributes the `File` itself,
475
642
  * `useWorkflowSubmit` stores it through `POST /workflows/uploads` before
476
643
  * starting the run, and the input carries the upload id — which a step reads
477
- * windows of with `readUpload`. Declaring the property in the workflow's
644
+ * windows of with `stepReadUpload`. Declaring the property in the workflow's
478
645
  * `uploads` list makes `<WorkflowFields>` render exactly this, so a declared
479
646
  * form needs no file markup at all.
480
647
  *
481
648
  * **Without it the field describes the file and does not read it.** `read`
482
649
  * exists for the cases where the bytes really are small and really are the
483
650
  * input — a CSV of ids, a config — and the size is the author's to check. See
484
- * {@link FileRead} for the four values; `upload` is shorthand for
651
+ * {@link FileReadMode} for the four values; `upload` is shorthand for
485
652
  * `read="upload"`.
486
653
  *
487
654
  * Otherwise accepts every `<input>` attribute except `name`, `className` and
@@ -512,7 +679,7 @@ function FileField({ name, label, hint, className, read = "none", upload = false
512
679
  });
513
680
  }
514
681
  //#endregion
515
- //#region components/form.tsx
682
+ //#region src/components/form.tsx
516
683
  /** @jsxImportSource react */
517
684
  /**
518
685
  * Simple forms, for the pages that are not conversations.
@@ -654,7 +821,7 @@ function SubmitButton({ children, pending = false, pendingLabel = "Working…",
654
821
  });
655
822
  }
656
823
  //#endregion
657
- //#region components/upload-progress.tsx
824
+ //#region src/components/upload-progress.tsx
658
825
  /** @jsxImportSource react */
659
826
  /**
660
827
  * The track behind the fill, as a tint of the theme's own ink.
@@ -766,1287 +933,1418 @@ function UploadProgressBar({ upload, onPause, onResume, className }) {
766
933
  });
767
934
  }
768
935
  //#endregion
769
- //#region _recover-run.ts
936
+ //#region src/use-workflows.ts
770
937
  /**
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
938
+ * The agent's declared workflows — what a form is rendered FROM.
794
939
  *
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.
940
+ * Split out of `use-workflow-form.ts` at the 500-line cap, along the seam that
941
+ * file's own doc already drew: it held "the two hooks a FORM needs", and only
942
+ * one of them is about a RUN. This is the other one, and it shares nothing with
943
+ * its former neighbours but the client ref every hook here uses — no state, no
944
+ * run id, no upload.
945
+ */
946
+ /**
947
+ * Read the agent's declared workflows.
799
948
  *
800
- * ## A failed lookup is REPORTED
949
+ * What `<WorkflowFields>` renders a form FROM: each summary carries the JSON
950
+ * Schema of that workflow's input, converted server-side precisely so a browser
951
+ * can read it.
801
952
  *
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.
953
+ * The failure is reported rather than swallowed, because the alternative is an
954
+ * empty list — which renders as a form with no fields and reads as "this agent
955
+ * declares no workflows" about an agent that was merely unreachable.
808
956
  *
809
- * ## It is OPT-IN
957
+ * @example
958
+ * ```tsx
959
+ * import { useWorkflows } from "@alexkroman1/aai-ui";
810
960
  *
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.
961
+ * // A page rendering its own chrome from the listing — a picker, say. A form
962
+ * // for ONE workflow wants `<WorkflowFields workflow="name" />` instead,
963
+ * // which does this lookup itself.
964
+ * function WorkflowPicker({ onPick }: { onPick: (name: string) => void }) {
965
+ * const { workflows, loading, error } = useWorkflows();
966
+ * if (loading) return <p>Loading…</p>;
967
+ * if (error !== undefined) return <p role="alert">{error}</p>;
968
+ * return (
969
+ * <ul>
970
+ * {workflows.map((summary) => (
971
+ * <li key={summary.name}>
972
+ * <button type="button" onClick={() => onPick(summary.name)}>
973
+ * {summary.description ?? summary.name}
974
+ * </button>
975
+ * </li>
976
+ * ))}
977
+ * </ul>
978
+ * );
979
+ * }
980
+ * ```
818
981
  *
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.
982
+ * @param opts - See {@link UseWorkflowsOptions}.
983
+ * @returns The listing, its loading flag and its failure — see
984
+ * {@link UseWorkflowsResult}.
823
985
  *
824
- * @internal
986
+ * @public
825
987
  */
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;
988
+ function useWorkflows(opts = {}) {
989
+ const { api, skip = false } = opts;
990
+ const [state, setState] = useState({
991
+ workflows: [],
992
+ loading: !skip,
993
+ error: void 0
994
+ });
995
+ const getClient = useWorkflowApiRef(api);
831
996
  useEffect(() => {
832
- if (!enabled || key === void 0) return;
997
+ if (skip) return;
833
998
  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);
999
+ getClient().list().then((workflows) => {
1000
+ if (!cancelled) setState({
1001
+ workflows,
1002
+ loading: false,
1003
+ error: void 0
1004
+ });
840
1005
  }).catch((err) => {
841
1006
  if (cancelled) return;
842
- handlers.current.onError(errorMessage(err));
843
- setRecovering(false);
1007
+ setState({
1008
+ workflows: [],
1009
+ loading: false,
1010
+ error: errorMessage(err)
1011
+ });
844
1012
  });
845
1013
  return () => {
846
1014
  cancelled = true;
847
1015
  };
848
- }, [
849
- enabled,
850
- key,
851
- workflow,
852
- getClient
853
- ]);
854
- return recovering;
1016
+ }, [skip, getClient]);
1017
+ return state;
855
1018
  }
856
1019
  //#endregion
857
- //#region _run-controls.ts
1020
+ //#region src/components/workflow-fields.tsx
1021
+ /** @jsxImportSource react */
858
1022
  /**
859
- * The two things a page does TO a run it started, bound to the run it has.
1023
+ * A form built from a workflow's declared input schema.
860
1024
  *
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.
1025
+ * `GET workflows` reports each workflow's `inputSchema` as JSON Schema — the
1026
+ * zod schema an author wrote in `agent.ts`, converted at listing time precisely
1027
+ * so a browser can read it. This is what reads it: one `<WorkflowFields>` and a
1028
+ * workflow's form matches its schema by construction, so adding a field to the
1029
+ * schema adds it to the page and nothing can drift.
866
1030
  *
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.
1031
+ * ## It covers SCALARS, and says so rather than guessing
1032
+ *
1033
+ * A string, number, integer, boolean or enum has one obvious control each. A
1034
+ * nested object or an array does not — every choice (a JSON textarea, a repeater,
1035
+ * a comma-separated string) is a guess about what the author meant, and a guess
1036
+ * that produces a value the schema then rejects is worse than no field at all.
1037
+ * So those are SKIPPED, and the fields for them are written by hand: every field
1038
+ * in this package is a plain named control, so a hand-written one composes with
1039
+ * a generated one inside the same {@link Form}.
872
1040
  */
873
1041
  /**
874
- * Bind `wake` and `cancel` to whatever run the hook is currently following.
1042
+ * Render one field per scalar property of a workflow's input schema.
875
1043
  *
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.
1044
+ * Pass the workflow's NAME and the schema is fetched here; pass a
1045
+ * {@link WorkflowSummary} you already hold and nothing is fetched. The name form
1046
+ * is the one a page usually wants — it is the same string the submit hook takes,
1047
+ * and the alternative is three lines (`useWorkflows()`, a `.find()` by name, and
1048
+ * folding that lookup's error into the form's) whose only product is this
1049
+ * component's argument.
879
1050
  *
880
- * @internal
881
- */
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
- /**
897
- * Where an upload's ID survives a page RELOAD.
1051
+ * Renders nothing when the workflow declared no schema — a workflow with no
1052
+ * declared input takes anything, and a form for "anything" is not a form — and
1053
+ * nothing while a named lookup is still in flight, so the hand-written fields
1054
+ * beside it are not reordered when the schema lands.
898
1055
  *
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.
913
- *
914
- * ## A FINGERPRINT, because a `File` has no name a page can address
915
- *
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.
922
- *
923
- * ## `sessionStorage`, deliberately
924
- *
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.
1056
+ * @example
1057
+ * ```tsx no-check
1058
+ * import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
1059
+ * from "@alexkroman1/aai-ui";
1060
+ * import type { transcribe } from "./agent.ts";
930
1061
  *
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.
1062
+ * function StartRun() {
1063
+ * const { submitForm, pending, error } = useWorkflowSubmit<typeof transcribe>("transcribe");
1064
+ * return (
1065
+ * <Form onSubmit={submitForm} error={error}>
1066
+ * <WorkflowFields workflow="transcribe" />
1067
+ * <SubmitButton pending={pending}>Transcribe</SubmitButton>
1068
+ * </Form>
1069
+ * );
1070
+ * }
1071
+ * ```
938
1072
  *
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.
943
- */
944
- const MAX_REMEMBERED = 32;
945
- /** One form's slot in storage. */
946
- function keyFor(scope) {
947
- return `${PREFIX$1}${scope}`;
948
- }
949
- /**
950
- * What names this file across a reload.
1073
+ * @param props - Field-set props.
951
1074
  *
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.
1075
+ * @public
954
1076
  */
955
- function fingerprint(file) {
956
- return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
1077
+ function WorkflowFields({ workflow }) {
1078
+ const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1079
+ const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
1080
+ useDeclareFieldsPending(loading);
1081
+ const schema = asObjectSchema(summary?.inputSchema);
1082
+ if (!schema?.properties) return null;
1083
+ const required = new Set(schema.required ?? []);
1084
+ const uploads = new Set(summary?.uploads ?? []);
1085
+ return /* @__PURE__ */ jsx(Fragment, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
1086
+ name,
1087
+ property,
1088
+ required: required.has(name),
1089
+ upload: uploads.has(name)
1090
+ }, name)) });
957
1091
  }
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 {};
1092
+ /** One property's control, or nothing when its type has no obvious one. */
1093
+ function SchemaField({ name, property, required, upload = false }) {
1094
+ const label = humanize(name);
1095
+ const hint = property.description === void 0 ? {} : { hint: property.description };
1096
+ const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
1097
+ if (upload) return /* @__PURE__ */ jsx(FileField, {
1098
+ name,
1099
+ label,
1100
+ required,
1101
+ upload: true,
1102
+ ...hint
1103
+ });
1104
+ if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
1105
+ name,
1106
+ label,
1107
+ required,
1108
+ options: property.enum.map((value) => String(value)),
1109
+ ...hint,
1110
+ ...defaults
1111
+ });
1112
+ const type = typeOf(property);
1113
+ switch (type) {
1114
+ case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
1115
+ name,
1116
+ label,
1117
+ defaultChecked: property.default === true,
1118
+ ...hint
1119
+ });
1120
+ case "number":
1121
+ case "integer": return /* @__PURE__ */ jsx(NumberField, {
1122
+ name,
1123
+ label,
1124
+ required,
1125
+ step: type === "integer" ? 1 : "any",
1126
+ ...hint,
1127
+ ...defaults
1128
+ });
1129
+ case "string": return /* @__PURE__ */ jsx(TextField, {
1130
+ name,
1131
+ label,
1132
+ required,
1133
+ ...hint,
1134
+ ...defaults
1135
+ });
1136
+ default: return null;
967
1137
  }
968
1138
  }
969
- function write(scope, entries) {
970
- try {
971
- globalThis.sessionStorage?.setItem(keyFor(scope), JSON.stringify(entries));
972
- } catch {}
1139
+ /** A property's type, taking the first non-null member of a union. */
1140
+ function typeOf(property) {
1141
+ const { type } = property;
1142
+ if (typeof type === "string") return type;
1143
+ return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
1144
+ }
1145
+ /** The listing's `unknown` schema as the object shape this reads, when it is one. */
1146
+ function asObjectSchema(schema) {
1147
+ return isRecord(schema) ? schema : void 0;
973
1148
  }
974
1149
  /**
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.
1150
+ * A property name as a label — `recordingId` → `Recording id`.
978
1151
  *
979
- * @internal
1152
+ * A default, not a policy: a schema whose labels matter should carry a
1153
+ * `.describe()`, and an author who wants exact control writes the field.
980
1154
  */
981
- function recallUploadId(scope, file) {
982
- const found = read(scope)[fingerprint(file)];
983
- return typeof found === "string" ? found : void 0;
1155
+ function humanize(name) {
1156
+ const spaced = name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().toLowerCase();
1157
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
984
1158
  }
1159
+ //#endregion
1160
+ //#region src/components/workflow-progress.tsx
1161
+ /** @jsxImportSource react */
985
1162
  /**
986
- * Remember the id this file is being stored under.
1163
+ * What a run has said so far, rendered.
987
1164
  *
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.
1165
+ * The complement of a status line, and the reason both exist: a run is
1166
+ * `running` for its whole life, so a one-round job and a ten-round one look
1167
+ * identical while they happen. These lines come from the run itself (`stepReport()`
1168
+ * in a `"use step"` body), which is the only channel a workflow has before it
1169
+ * produces an output.
991
1170
  *
992
- * @internal
993
- */
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);
1002
- }
1003
- /**
1004
- * Forget it: the agent holds nothing resumable under this id.
1171
+ * Four rules are baked in, and they are why this is a component rather than
1172
+ * three lines each page writes for itself — the two templates that had written
1173
+ * it had written three of them, comments included:
1005
1174
  *
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.
1175
+ * - **It renders nothing until there is something to render.** `supported` is
1176
+ * what keeps this from being an empty box forever on an agent deployed before
1177
+ * progress streams existed: "wrote nothing yet" and "serves no stream" are
1178
+ * indistinguishable from the chunk list alone.
1179
+ * - **The lines are TEXT, not elements.** They are append-only and two rounds
1180
+ * legitimately produce identical text, so there is no stable per-line key to
1181
+ * give React. Joining sidesteps the question instead of suppressing the lint
1182
+ * rule that asks it.
1183
+ * - **They REPLAY.** Chunks are retained with the run, so a reload mid-run —
1184
+ * or opening a finished run tomorrow — shows how it got there rather than an
1185
+ * empty box. That is `useWorkflowProgress`'s doing; this is what makes it
1186
+ * visible.
1187
+ * - **They are ANNOUNCED**, for the reason the first paragraph gives: this is
1188
+ * the only channel a run has before it produces an output, and a `<pre>` that
1189
+ * grows is a silent one. A screen-reader user pressing "Digest" got nothing
1190
+ * between the click and a terminal state minutes later — no "fetching", no
1191
+ * "summarising", no evidence the button did anything. See `role="log"` below.
1192
+ * The six pages that render this pass only `className`, so no template could
1193
+ * have fixed it locally; that is what makes it this component's job.
1009
1194
  *
1010
- * @internal
1195
+ * @example
1196
+ * ```tsx
1197
+ * import { WorkflowProgress } from "@alexkroman1/aai-ui";
1198
+ *
1199
+ * function RunPanel({ runId }: { runId: string }) {
1200
+ * return <WorkflowProgress runId={runId} />;
1201
+ * }
1202
+ * ```
1203
+ *
1204
+ * @param props - Progress-log props.
1205
+ *
1206
+ * @public
1011
1207
  */
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);
1208
+ function WorkflowProgress({ runId, api, className, placeholder, lines }) {
1209
+ const { progress, streaming, supported } = useWorkflowProgress(runId, omitUndefined({ api }));
1210
+ const text = useMemo(() => {
1211
+ const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
1212
+ return shown.length === 0 ? null : shown.join("\n");
1213
+ }, [progress, lines]);
1214
+ if (!supported || text === null) return placeholder ?? null;
1215
+ return /* @__PURE__ */ jsxs("pre", {
1216
+ role: "log",
1217
+ "aria-live": "polite",
1218
+ "aria-atomic": "false",
1219
+ className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
1220
+ children: [text, streaming && "\n…"]
1221
+ });
1018
1222
  }
1019
1223
  //#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
- }
1224
+ //#region src/page.tsx
1225
+ /** @jsxImportSource react */
1029
1226
  /**
1030
- * Send one file, waiting out however many pauses the person takes.
1227
+ * `mountPage()` — mount a WORKFLOW APP's UI: React, theme, no session.
1031
1228
  *
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.
1229
+ * The twin of `mountClient()` for an agent whose front door is a form rather than a
1230
+ * microphone (`workflowApp()`). It is a separate entry rather than
1231
+ * an option on `mountClient()` because of what `mountClient()` unavoidably does: it
1232
+ * constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
1233
+ * graph, and a microphone request. A flag would have to make all of that
1234
+ * conditional, and every session hook would then have to answer "what does this
1235
+ * mean with no session?" — so the honest split is two mounts. A page that wants
1236
+ * voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
1037
1237
  *
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.
1238
+ * Authoring is otherwise identical — the file is still `client.tsx`, still
1239
+ * React, still Tailwind, still the same theme tokens — so a workflow app reads
1240
+ * like every other agent. What it reaches for instead of `useSession()` is
1241
+ * `createWorkflowApi()` / `useWorkflowRun()`.
1041
1242
  */
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
- }
1056
- }
1057
1243
  /**
1058
- * A gate, open.
1244
+ * Mount a page for an agent whose work happens in workflows.
1059
1245
  *
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.
1246
+ * There is deliberately no session, no microphone, and no socket: the component
1247
+ * talks to the agent over the workflow HTTP API
1248
+ * (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
1113
1249
  *
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.
1250
+ * @example
1251
+ * ```tsx
1252
+ * import { createWorkflowApi, mountPage, useWorkflowRun } from "@alexkroman1/aai-ui";
1253
+ * import { useState } from "react";
1123
1254
  *
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.
1127
- */
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
- }
1134
- /**
1135
- * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
1136
- * being sent.
1255
+ * // Hoisted: a client built in render is a new object every render.
1256
+ * const api = createWorkflowApi();
1137
1257
  *
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.
1258
+ * function App() {
1259
+ * const [runId, setRunId] = useState<string>();
1260
+ * const { run } = useWorkflowRun(runId, { api });
1261
+ * return (
1262
+ * <button
1263
+ * type="button"
1264
+ * onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
1265
+ * >
1266
+ * {run ? run.status : "Start"}
1267
+ * </button>
1268
+ * );
1269
+ * }
1144
1270
  *
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.
1271
+ * mountPage({ name: "Digest", component: App });
1272
+ * ```
1157
1273
  *
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.
1274
+ * @throws If the target element is not found in the DOM.
1162
1275
  *
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`).
1276
+ * @public
1166
1277
  */
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
- };
1278
+ function mountPage(config) {
1279
+ const container = resolveContainer(config.target);
1280
+ setPageTitle(config.name);
1281
+ return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
1176
1282
  }
1283
+ //#endregion
1284
+ //#region src/use-download-url.ts
1177
1285
  /**
1178
- * The id to store this file under: the one a previous page load was using, or a
1179
- * fresh one.
1286
+ * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
1287
+ * `<img>` or `<a download>` will accept.
1180
1288
  *
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.
1289
+ * `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
1290
+ * same bearer every workflow route does, and neither `<audio src>` nor
1291
+ * `<a href>` can send one. So every page that plays back what a run WROTE ends
1292
+ * up at the same four lines — `download` → `createObjectURL` → state — and the
1293
+ * two that are really the point are the two the four lines are wrapped in:
1185
1294
  *
1186
- * Three answers, and the third is why this is not just a storage lookup:
1295
+ * - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
1296
+ * life of the DOCUMENT. Miss it and every completed run's audio stays resident
1297
+ * until the tab closes, which on a page people run all day is a leak measured
1298
+ * in the size of the files.
1299
+ * - **A `cancelled` flag.** A second run settling while the first download is
1300
+ * still in flight otherwise sets state from the stale one, and the page plays
1301
+ * the previous run's audio under the current run's transcript — a wrong answer
1302
+ * that looks like a right one.
1187
1303
  *
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.
1304
+ * Two templates had written this hook, identically, doc paragraph included, and
1305
+ * `aai-ui` exported no download helper at all. Both also faked `pending` by
1306
+ * checking `url === undefined && error === undefined`, which reads "idle" and
1307
+ * "downloading" as the same thing — so this reports it.
1198
1308
  */
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
- }
1309
+ /** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
1310
+ const IDLE = { pending: false };
1311
+ /** Bytes in flight. Shared for the same reason as {@link IDLE}. */
1312
+ const PENDING = { pending: true };
1223
1313
  /**
1224
- * Replace every `File` in a submitted form with the id of a stored upload,
1225
- * reporting how far each one has got.
1226
- *
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.
1314
+ * Read an upload's bytes and hand back a URL a DOM element can use.
1231
1315
  *
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.
1316
+ * @param uploadId - The id a completed run reported, or `undefined` before one
1317
+ * exists — which is what a page passes straight through while it waits, and
1318
+ * reports as idle rather than pending.
1319
+ * @param opts - See {@link UseDownloadUrlOptions}.
1320
+ * @returns See {@link UseDownloadUrlResult}.
1235
1321
  *
1236
- * ## `uploadStream`, not `upload`, and the id is the reason
1322
+ * @example
1323
+ * ```tsx no-check
1324
+ * import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
1325
+ * import type { spokenSummary } from "./agent.ts";
1237
1326
  *
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.
1327
+ * function Playback() {
1328
+ * const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
1329
+ * const output = run?.status === "completed" ? run.output : undefined;
1330
+ * const audio = useDownloadUrl(output?.audio);
1331
+ * if (audio.pending) return <p>Fetching audio…</p>;
1332
+ * if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
1333
+ * return audio.url === undefined ? null : (
1334
+ * <a href={audio.url} download="summary.mp3">
1335
+ * Download
1336
+ * </a>
1337
+ * );
1338
+ * }
1339
+ * ```
1244
1340
  *
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.
1341
+ * @public
1248
1342
  */
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);
1343
+ function useDownloadUrl(uploadId, opts = {}) {
1344
+ const [state, setState] = useState(IDLE);
1345
+ const getClient = useWorkflowApiRef(opts.api);
1346
+ useEffect(() => {
1347
+ if (uploadId === void 0) {
1348
+ setState(IDLE);
1349
+ return;
1272
1350
  }
1273
- if (claimed.complete) {
1274
- report({
1275
- ...position,
1276
- loaded: file.size,
1277
- total: file.size,
1278
- fraction: 1,
1279
- paused: false
1351
+ let cancelled = false;
1352
+ let objectUrl;
1353
+ setState(PENDING);
1354
+ getClient().download(uploadId).then((blob) => {
1355
+ if (cancelled) return;
1356
+ objectUrl = URL.createObjectURL(blob);
1357
+ setState({
1358
+ url: objectUrl,
1359
+ pending: false
1280
1360
  });
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
- })
1361
+ }).catch((err) => {
1362
+ if (!cancelled) setState({
1363
+ error: errorMessage(err),
1364
+ pending: false
1297
1365
  });
1298
1366
  });
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;
1316
- }
1317
- return out;
1367
+ return () => {
1368
+ cancelled = true;
1369
+ if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
1370
+ };
1371
+ }, [uploadId, getClient]);
1372
+ return state;
1318
1373
  }
1319
1374
  //#endregion
1320
- //#region _upload-pause.ts
1375
+ //#region src/use-run-key.ts
1321
1376
  /**
1322
- * The two buttons a page puts over a live upload, bound to whichever gate the
1323
- * submission is currently holding.
1377
+ * The handle a page keeps on the runs it started, across a reload.
1324
1378
  *
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.
1379
+ * `useWorkflowSubmit` is what makes a run survivable — the run id is that
1380
+ * hook's own state, so a refresh loses it while the run carries on — and the
1381
+ * `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
1382
+ * there is no per-user filtering behind `find`, so the key IS the scoping
1383
+ * mechanism. Choosing one is easy to get wrong in three separate ways, and six
1384
+ * shipped templates had each written the same twenty lines to get it right.
1385
+ * This is those lines.
1386
+ *
1387
+ * **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
1388
+ * so a page resumes its own run across a reload with nothing written at the
1389
+ * call site — six of six page templates passed `useRunKey()` and
1390
+ * `recover: true`, which is a default in the wrong place. The hook stays
1391
+ * PUBLIC for the page that wants to choose: an app with accounts passes the
1392
+ * ACCOUNT's own id instead, and a run then follows the person to a new device,
1393
+ * which is a promise only a login can keep; a page whose run outlives the tab
1394
+ * passes `useRunKey({ storage: "local" })`.
1395
+ *
1396
+ * ## Three properties, and the rejected alternatives are why each one matters
1397
+ *
1398
+ * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
1399
+ * A key derived from the input collides the moment two people submit the same
1400
+ * thing, and they then recover each other's runs; it also carries what they
1401
+ * typed into a lookup token the platform deliberately stopped logging.
1402
+ * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
1403
+ * `POST /workflows/runs` allows a key.
1404
+ * - **Minted once per load and written back for the next one**, which is the
1405
+ * whole mechanism: the load that presses the button records the key with the
1406
+ * run, and the load after it finds the run by producing the same key.
1407
+ *
1408
+ * Storage rather than the page's own URL, for all of them. A `?key=` parameter
1409
+ * survives more (a new tab, a bookmark, a shared link) and that is the problem:
1410
+ * a URL is pasted into chats, copied into referrers and kept in history, and
1411
+ * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
1412
+ * it. An app with accounts should pass the ACCOUNT's own id here instead, and
1413
+ * then a run follows the person to a new device, which is a promise only a login
1414
+ * can keep.
1415
+ *
1416
+ * ## The storage is the caller's decision, and it is not a detail
1417
+ *
1418
+ * `"session"` (the default) dies with the tab, which covers exactly the
1419
+ * interruption most pages have — a reload, a same-tab navigation, a crashed tab
1420
+ * — and is the same lifetime as this package's other two stores, the session
1421
+ * resume id (`session-resume-store.ts`) and the upload recall
1422
+ * (`_upload-recall.ts`), so both halves of a reload make the same promise.
1423
+ *
1424
+ * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
1425
+ * between digests and may live a month, where closing the browser on Tuesday and
1426
+ * coming back on Friday to press Stop is the ordinary case rather than an edge
1427
+ * one, and a tab-scoped key would answer that with an empty form beside a run
1428
+ * still posting somewhere. It is as far as a key can go without a login, and no
1429
+ * further. `podcast-digest` is that template, and the reason this hook is still
1430
+ * called by name anywhere; the other five take the tab-scoped default the
1431
+ * submit hook mints for them.
1432
+ *
1433
+ * ## Anything ELSE a page stores back must be VALIDATED on read
1434
+ *
1435
+ * This key needs no validation, and it is worth saying why, because it is the
1436
+ * exception: any string is a legal key, so a value from storage can only fail to
1437
+ * match a run. A page that remembers something more — which MODE submitted, say
1438
+ * — is remembering a value it will turn into a name, and storage hands back a
1439
+ * string some earlier version of that page wrote: a renamed mode, a hand-edited
1440
+ * value, a slot another app on the origin happens to share. Unchecked, that
1441
+ * starts a run called `undefined` and answers a 400 nobody typed. Check it
1442
+ * against the page's own list on the way out (`recalledMode` in
1443
+ * `transcription-workflow/recover.ts` is the worked example) — the recall is the
1444
+ * page's, the validation is not optional.
1445
+ *
1446
+ * ## The slot is keyed by the page's own URL
1447
+ *
1448
+ * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
1449
+ * would have two agents scaffolded from the same template recover each other's
1450
+ * runs. The key is the page's own directory — resolved through `"./"`, which
1451
+ * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
1452
+ * find the same key. Same call `session-resume-store.ts` makes, for the same
1453
+ * reason.
1454
+ *
1455
+ * One key per PAGE is right even for a page driving several workflows: `find` is
1456
+ * scoped by workflow as well as by key, so three hooks sharing one key recover
1457
+ * three separate runs. `transcription-workflow` is that page.
1458
+ *
1459
+ * Every access is guarded. Storage THROWS outright in some contexts (Safari
1460
+ * private mode, an iframe blocked by policy) and is ABSENT in others (any
1461
+ * server-side render), and a page that cannot remember its key must degrade to
1462
+ * the behaviour it would have had anyway — one run per load — rather than
1463
+ * failing to render.
1331
1464
  */
1465
+ /** Where a run key lives, namespaced like this package's two other stores. */
1466
+ const PREFIX$1 = "aai:run-key:";
1467
+ /** This page's own slot — see "The slot is keyed by the page's own URL". */
1468
+ function slotFor() {
1469
+ return urlSlot(PREFIX$1, "./");
1470
+ }
1332
1471
  /**
1333
- * Bind pause/resume to the live submission's gate.
1334
- *
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.
1472
+ * Read the key this page already has, or mint and remember one.
1338
1473
  *
1339
- * @internal
1474
+ * Not exported: a page that wants a key wants it for the life of a component,
1475
+ * which is what the hook is. Calling this per render would mint a fresh key and
1476
+ * hand `recover` one nothing was ever started under.
1340
1477
  */
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
- };
1478
+ function mintRunKey(storage) {
1479
+ const slot = slotFor();
1480
+ const stored = storageGet(storage, slot);
1481
+ if (stored !== void 0) return stored;
1482
+ const minted = crypto.randomUUID();
1483
+ storageSet(storage, slot, minted);
1484
+ return minted;
1358
1485
  }
1359
- //#endregion
1360
- //#region use-workflow-form.ts
1361
1486
  /**
1362
- * The two hooks a FORM needs, as against the one a status view does.
1487
+ * The key `useWorkflowSubmit` uses when the page named none.
1363
1488
  *
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`.
1489
+ * Two things it does that a plain `useRunKey()` at the call site cannot, and
1490
+ * both are about a page that DID name one:
1369
1491
  *
1370
- * ## `useWorkflowSubmit` — a form's two halves in one hook
1492
+ * - **It mints nothing when the caller has a key.** Minting writes to storage,
1493
+ * so an unconditional `useRunKey()` inside the hook would leave a slot behind
1494
+ * on every page that passes an account id and never reads it back.
1495
+ * - **It stays reactive to the caller's key.** A key that arrives late — an
1496
+ * account id resolved after a login — must reach the lookup, which re-asks on
1497
+ * a changed key by design; freezing it into `useState` would pin the page to
1498
+ * whatever it held on its first render.
1371
1499
  *
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.
1500
+ * The minted half is still frozen for the component's life, which is what
1501
+ * `useRunKey` freezes it for: a fresh key per render would record every run
1502
+ * under a name the next load cannot produce.
1379
1503
  *
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.
1504
+ * @param explicit - The caller's own key, or undefined for a page with none.
1505
+ * @returns The key to record runs under and look them up by.
1384
1506
  *
1385
- * ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
1507
+ * @internal
1508
+ */
1509
+ function useDefaultRunKey(explicit) {
1510
+ const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
1511
+ return explicit ?? minted;
1512
+ }
1513
+ /**
1514
+ * A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
1386
1515
  *
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.
1516
+ * @param options - See the module doc for the whole argument. The storage kind
1517
+ * is read once, when the key is minted: a value that changed afterwards would
1518
+ * be asking to move a key that has already been recorded with a run.
1519
+ * @returns The key to record runs under and to look them up by — the same one
1520
+ * for the life of the component, and for the next load in the same tab (or the
1521
+ * same browser, under `"local"`).
1394
1522
  */
1523
+ function useRunKey(options = {}) {
1524
+ const { storage = "session" } = options;
1525
+ const [key] = useState(() => mintRunKey(storage));
1526
+ return key;
1527
+ }
1528
+ //#endregion
1529
+ //#region src/_recover-run.ts
1395
1530
  /**
1396
- * Read the agent's declared workflows.
1531
+ * Finding a run again when the page has lost its id.
1397
1532
  *
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.
1533
+ * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
1534
+ * which was only half true of the hooks above it: the run id lived in plain
1535
+ * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
1536
+ * live run with nothing anywhere able to name it. The run really did continue;
1537
+ * the person really could not get back to it.
1401
1538
  *
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.
1539
+ * `StartOptions.key` is the handle that survives that, and it always was — a
1540
+ * caller's own name for a run, indexed by the agent, read back with
1541
+ * `find(workflow, key)`. What was missing is the two lines that ASK. This is
1542
+ * them, plus the four decisions they turn out to carry.
1405
1543
  *
1406
- * @example
1407
- * ```tsx
1408
- * import { useWorkflows } from "@alexkroman1/aai-ui";
1544
+ * ## It is a MOUNT-time act, not "whenever there is no run"
1409
1545
  *
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
- * ```
1546
+ * The tempting spelling is "if we hold no run id, look one up", and it breaks
1547
+ * `reset()`: a form put back to its initial state holds no run id, so the next
1548
+ * pass would re-adopt the very run the person had just dismissed — a Clear
1549
+ * button that clears nothing. So the lookup runs once per mount (and again only
1550
+ * if the KEY changes, which is a different person's run), and every later
1551
+ * absence of a run id is taken at face value.
1430
1552
  *
1431
- * @param opts - See {@link UseWorkflowsOptions}.
1432
- * @returns The listing, its loading flag and its failure — see
1433
- * {@link UseWorkflowsResult}.
1553
+ * ## The lookup NEVER wins a race against a submit
1434
1554
  *
1435
- * @public
1555
+ * A person who reloads and immediately submits has started the run they want,
1556
+ * and an answer that was already in flight names an older one. The caller
1557
+ * therefore adopts through `current ?? found`: the recovered id fills an empty
1558
+ * slot and never replaces a full one.
1559
+ *
1560
+ * ## A failed lookup is REPORTED
1561
+ *
1562
+ * The alternative is a page that quietly shows an empty form to somebody whose
1563
+ * run is live, who then starts a second one — the duplicated work the key
1564
+ * exists to prevent, and on a workflow app that is real money. A person who has
1565
+ * never run anything pays a banner they can ignore. Same trade as
1566
+ * `useWorkflows`, for the same reason: an empty answer here is a confident
1567
+ * false statement.
1568
+ *
1569
+ * ## It is ON by default, and it used to be opt-in
1570
+ *
1571
+ * The argument for opt-in was that a `key` on its own means only "record this
1572
+ * with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
1573
+ * means, there being no page to put a run back on. A FORM is the other case: it
1574
+ * is the page, and losing the run is the thing it cannot recover from. Six of
1575
+ * six page templates wrote `useRunKey()` and `recover: true` together, which is
1576
+ * the same shape `session-resume-store.ts` names on the voice side — a default
1577
+ * in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
1578
+ *
1579
+ * `enabled` remains, because `recover: false` remains: a page whose form must
1580
+ * always open empty says so, and then nothing here runs.
1436
1581
  */
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);
1582
+ /**
1583
+ * Look up the newest run for a key, once, as the component mounts.
1584
+ *
1585
+ * @param opts - See {@link RecoverRunOptions}.
1586
+ * @returns Whether the lookup is still out. A caller folds it into its own
1587
+ * `pending`, because a form offering Submit while a live run is arriving is a
1588
+ * form inviting a second one.
1589
+ *
1590
+ * @internal
1591
+ */
1592
+ function useRecoveredRun(opts) {
1593
+ const { workflow, key, enabled, getClient } = opts;
1594
+ const [recovering, setRecovering] = useState(enabled);
1595
+ const handlers = useRef(opts);
1596
+ handlers.current = opts;
1445
1597
  useEffect(() => {
1446
- if (skip) return;
1598
+ if (!enabled) return;
1447
1599
  let cancelled = false;
1448
- getClient().list().then((workflows) => {
1449
- if (!cancelled) setState({
1450
- workflows,
1451
- loading: false,
1452
- error: void 0
1453
- });
1600
+ setRecovering(true);
1601
+ getClient().find(workflow, key, { limit: 1 }).then((found) => {
1602
+ if (cancelled) return;
1603
+ const newest = found[0];
1604
+ if (newest !== void 0) handlers.current.onFound(newest.runId);
1605
+ setRecovering(false);
1454
1606
  }).catch((err) => {
1455
1607
  if (cancelled) return;
1456
- setState({
1457
- workflows: [],
1458
- loading: false,
1459
- error: errorMessage(err)
1460
- });
1608
+ handlers.current.onError(errorMessage(err));
1609
+ setRecovering(false);
1461
1610
  });
1462
1611
  return () => {
1463
1612
  cancelled = true;
1464
1613
  };
1465
- }, [skip, getClient]);
1466
- return state;
1614
+ }, [
1615
+ enabled,
1616
+ key,
1617
+ workflow,
1618
+ getClient
1619
+ ]);
1620
+ return recovering;
1467
1621
  }
1622
+ //#endregion
1623
+ //#region src/_run-controls.ts
1468
1624
  /**
1469
- * Start a workflow from a form, and follow the run it creates.
1625
+ * The two things a page does TO a run it started, bound to the run it has.
1470
1626
  *
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`.
1627
+ * `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
1628
+ * handed it back, so a page that wanted "send it now" or "stop" had to hold an
1629
+ * `api` of its own purely to write `api.wake(runId)` — which is the whole reason
1630
+ * the two raw-primitive template pages keep a client at module scope. That is a
1631
+ * page carrying the transport to make up for a hook withholding its own state.
1474
1632
  *
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.
1633
+ * Both calls answer rather than fail when there is nothing to act on — `0`
1634
+ * sleeps ended, `false` this call did not end it — which is the SDK's own
1635
+ * contract for them (two tabs pressing Stop is ordinary), and it is what lets
1636
+ * the no-run case be the same answer rather than a special one a caller has to
1637
+ * branch on.
1638
+ */
1639
+ /**
1640
+ * Bind `wake` and `cancel` to whatever run the hook is currently following.
1482
1641
  *
1483
- * @example
1484
- * ```tsx no-check
1485
- * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
1486
- * import type { digest } from "./agent.ts";
1642
+ * @param runId - The live run, or `undefined` before one exists.
1643
+ * @param getClient - The stable getter from `useWorkflowApiRef`.
1644
+ * @returns Two callbacks, stable while `runId` is.
1487
1645
  *
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
- * ```
1646
+ * @internal
1647
+ */
1648
+ function useRunControls(runId, getClient) {
1649
+ return {
1650
+ wake: useCallback(async () => {
1651
+ if (runId === void 0) return 0;
1652
+ return await getClient().wake(runId);
1653
+ }, [runId, getClient]),
1654
+ cancel: useCallback(async () => {
1655
+ if (runId === void 0) return false;
1656
+ return await getClient().cancel(runId);
1657
+ }, [runId, getClient])
1658
+ };
1659
+ }
1660
+ //#endregion
1661
+ //#region src/_upload-pause.ts
1662
+ /**
1663
+ * The two buttons a page puts over a live upload, bound to whichever gate the
1664
+ * submission is currently holding.
1499
1665
  *
1500
- * @public
1666
+ * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
1667
+ * both reported the pause the same way: park the gate, then fold `paused` into
1668
+ * the status the bar is already drawing. Folded rather than replaced, because
1669
+ * everything else about that status — which file, how far, of how many — is
1670
+ * still true. Two copies of that rule are two copies that can stop agreeing
1671
+ * about what a paused bar says.
1501
1672
  */
1502
- function useWorkflowSubmit(workflow, opts = {}) {
1503
- const { api, key, recover = false, wait, intervalMs, parallel } = opts;
1673
+ /**
1674
+ * Bind pause/resume to the live submission's gate.
1675
+ *
1676
+ * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
1677
+ * stable across submissions rather than being re-created per gate.
1678
+ * @param setUpload - The status setter the progress bar renders from.
1679
+ *
1680
+ * @internal
1681
+ */
1682
+ function useUploadPause(getGate, setUpload) {
1683
+ const setPaused = useCallback((paused) => {
1684
+ setUpload((current) => current ? {
1685
+ ...current,
1686
+ paused
1687
+ } : current);
1688
+ }, [setUpload]);
1689
+ return {
1690
+ pauseUpload: useCallback(() => {
1691
+ getGate()?.pause();
1692
+ setPaused(true);
1693
+ }, [getGate, setPaused]),
1694
+ resumeUpload: useCallback(() => {
1695
+ getGate()?.resume();
1696
+ setPaused(false);
1697
+ }, [getGate, setPaused])
1698
+ };
1699
+ }
1700
+ //#endregion
1701
+ //#region src/_submission-state.ts
1702
+ /** The shared submission scaffold. See the module doc. */
1703
+ function useSubmissionState() {
1504
1704
  const [runId, setRunId] = useState(void 0);
1505
1705
  const [starting, setStarting] = useState(false);
1506
1706
  const [startError, setStartError] = useState(void 0);
1507
1707
  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();
1708
+ const current = useRef(void 0);
1709
+ const begin = useCallback((token) => {
1527
1710
  setStarting(true);
1528
1711
  setStartError(void 0);
1529
1712
  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
- ]);
1713
+ current.current?.gate.cancel();
1714
+ current.current = token;
1715
+ }, []);
1716
+ const end = useCallback((token) => {
1717
+ if (current.current !== token) return;
1718
+ current.current = void 0;
1719
+ setStarting(false);
1720
+ setUpload(void 0);
1721
+ }, []);
1556
1722
  const reset = useCallback(() => {
1557
- session.current?.gate.cancel();
1558
- session.current = void 0;
1723
+ current.current?.gate.cancel();
1724
+ current.current = void 0;
1559
1725
  setRunId(void 0);
1560
1726
  setStartError(void 0);
1561
1727
  setUpload(void 0);
1562
1728
  }, []);
1563
- const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => session.current?.gate, []), setUpload);
1729
+ const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => current.current?.gate, []), setUpload);
1564
1730
  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,
1731
+ runId,
1732
+ starting,
1733
+ startError,
1574
1734
  upload,
1575
- error: startError ?? tracked.error
1735
+ actions: useMemo(() => ({
1736
+ setRunId,
1737
+ setStartError,
1738
+ setUpload,
1739
+ begin,
1740
+ end,
1741
+ reset,
1742
+ pauseUpload,
1743
+ resumeUpload
1744
+ }), [
1745
+ begin,
1746
+ end,
1747
+ reset,
1748
+ pauseUpload,
1749
+ resumeUpload
1750
+ ])
1576
1751
  };
1577
1752
  }
1578
1753
  //#endregion
1579
- //#region components/workflow-fields.tsx
1580
- /** @jsxImportSource react */
1754
+ //#region src/_upload-recall.ts
1581
1755
  /**
1582
- * A form built from a workflow's declared input schema.
1756
+ * Where an upload's ID survives a page RELOAD.
1583
1757
  *
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.
1758
+ * A streamed upload is resumable because its id outlives the attempt that began
1759
+ * it — `_upload-files.ts` says so, and `_upload-session.ts` turns that into a
1760
+ * pause a person can press. Both of them hold the id in MEMORY: the walk's
1761
+ * `UploadSession` lives in a `useRef`, so a reload was the one interruption the
1762
+ * mechanism could not survive. Everything else was already in place — the windows
1763
+ * were still in the store, the agent could still name them
1764
+ * (`UploadInfo.ranges`), and the id was minted in the browser — and the browser
1765
+ * had thrown away the only name for them. So a person who refreshed at 90% of a
1766
+ * 200 MB recording sent the whole file again, which is the one interruption they
1767
+ * are most likely to cause on purpose.
1589
1768
  *
1590
- * ## It covers SCALARS, and says so rather than guessing
1769
+ * This is that name, written down. It is what tus-js-client's `urlStorage` and
1770
+ * Uppy's Golden Retriever sell, in the shape `session-resume-store.ts` already
1771
+ * uses for a session id.
1591
1772
  *
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.
1773
+ * ## A FINGERPRINT, because a `File` has no name a page can address
1602
1774
  *
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.
1775
+ * A file from a picker carries no path and no handle, so the key is what
1776
+ * tus-js-client fingerprints on: size, last-modified, type and name. Two
1777
+ * different files agreeing on all four is the case this cannot tell apart — and
1778
+ * the reason NOTHING here decides to resume. `_upload-files.ts` asks the agent
1779
+ * what the id actually holds before sending a byte to it, so a wrong hit costs
1780
+ * one `GET` and a fresh id rather than a corrupted upload.
1609
1781
  *
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.
1782
+ * ## `sessionStorage`, deliberately
1614
1783
  *
1615
- * @example
1616
- * ```tsx no-check
1617
- * import { Form, SubmitButton, WorkflowFields, useWorkflowSubmit }
1618
- * from "@alexkroman1/aai-ui";
1619
- * import type { transcribe } from "./agent.ts";
1784
+ * The same call `session-resume-store.ts` makes, for a reason that happens to be
1785
+ * stronger here: a reload and a same-tab navigation are exactly what this is for,
1786
+ * and an id from yesterday names an upload the agent's sweep has very likely
1787
+ * already collected. A tab is also the boundary the walk itself has — two tabs
1788
+ * uploading the same recording are two submissions.
1620
1789
  *
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
- * ```
1790
+ * Every access is guarded. Storage throws outright in Safari private mode and
1791
+ * under a blocking policy, and an upload that cannot be REMEMBERED must degrade
1792
+ * to the upload we would have done anyway rather than failing to start.
1793
+ */
1794
+ const PREFIX = "aai:upload:";
1795
+ /**
1796
+ * How many ids one form keeps.
1631
1797
  *
1632
- * @param props - Field-set props.
1798
+ * A cap rather than an expiry, because `sessionStorage` already expires with the
1799
+ * tab and an entry is ~80 bytes. What it bounds is the long-lived tab that
1800
+ * submits a hundred files: the oldest go first, and the id most likely to be
1801
+ * worth resuming is the one written last.
1802
+ */
1803
+ const MAX_REMEMBERED = 32;
1804
+ /** One form's slot in storage. */
1805
+ function keyFor(scope) {
1806
+ return `${PREFIX}${scope}`;
1807
+ }
1808
+ /**
1809
+ * What names this file across a reload.
1633
1810
  *
1634
- * @public
1811
+ * The four fields a browser gives a picked file that do not change between loads.
1812
+ * `name` last because it is the one a person can read in a debugger.
1635
1813
  */
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)) });
1814
+ function fingerprint(file) {
1815
+ return `${file.size}:${file.lastModified}:${file.type}:${file.name}`;
1650
1816
  }
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;
1817
+ /** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
1818
+ function read(scope) {
1819
+ const raw = storageGet("session", keyFor(scope));
1820
+ if (raw === void 0) return {};
1821
+ try {
1822
+ const parsed = JSON.parse(raw);
1823
+ return isRecord(parsed) ? parsed : {};
1824
+ } catch {
1825
+ return {};
1696
1826
  }
1697
1827
  }
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;
1828
+ function write(scope, entries) {
1829
+ storageSet("session", keyFor(scope), JSON.stringify(entries));
1707
1830
  }
1708
1831
  /**
1709
- * A property name as a label — `recordingId` → `Recording id`.
1832
+ * The id this file was last being stored under in this tab, if any.
1710
1833
  *
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.
1834
+ * A hit is a CANDIDATE and never a decision — see the module doc.
1835
+ *
1836
+ * @internal
1713
1837
  */
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);
1838
+ function recallUploadId(scope, file) {
1839
+ const found = read(scope)[fingerprint(file)];
1840
+ return typeof found === "string" ? found : void 0;
1717
1841
  }
1718
- //#endregion
1719
- //#region components/workflow-progress.tsx
1720
- /** @jsxImportSource react */
1721
1842
  /**
1722
- * What a run has said so far, rendered.
1843
+ * Remember the id this file is being stored under.
1723
1844
  *
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.
1845
+ * Called before the first byte leaves rather than after the last one lands: the
1846
+ * reload this exists for happens in between, and an id written at the end is an
1847
+ * id written for the one case that did not need it.
1729
1848
  *
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:
1849
+ * @internal
1850
+ */
1851
+ function rememberUploadId(scope, file, id) {
1852
+ const entries = read(scope);
1853
+ const key = fingerprint(file);
1854
+ delete entries[key];
1855
+ entries[key] = id;
1856
+ const keys = Object.keys(entries);
1857
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) delete entries[stale];
1858
+ write(scope, entries);
1859
+ }
1860
+ /**
1861
+ * Forget it: the agent holds nothing resumable under this id.
1733
1862
  *
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.
1863
+ * The other half of the agent deciding. Without it a swept upload is re-read on
1864
+ * every submission of the same file for the life of the tab, which is a round
1865
+ * trip spent learning the same 404.
1746
1866
  *
1747
- * @example
1748
- * ```tsx
1749
- * import { WorkflowProgress } from "@alexkroman1/aai-ui";
1867
+ * @internal
1868
+ */
1869
+ function forgetUploadId(scope, file) {
1870
+ const entries = read(scope);
1871
+ const key = fingerprint(file);
1872
+ if (!(key in entries)) return;
1873
+ delete entries[key];
1874
+ write(scope, entries);
1875
+ }
1876
+ //#endregion
1877
+ //#region src/_upload-session.ts
1878
+ /** Whether this rejection is an abort, in either of the two shapes runtimes throw. */
1879
+ function isAbortError(err) {
1880
+ return err instanceof Error && err.name === "AbortError";
1881
+ }
1882
+ /** A fresh upload id: a capability, so it is random rather than derived. */
1883
+ function randomUploadId() {
1884
+ return crypto.randomUUID().replaceAll("-", "");
1885
+ }
1886
+ /**
1887
+ * Send one file, waiting out however many pauses the person takes.
1750
1888
  *
1751
- * function RunPanel({ runId }: { runId: string }) {
1752
- * return <WorkflowProgress runId={runId} />;
1753
- * }
1754
- * ```
1889
+ * The loop from the module doc, written once: both hooks need exactly this and a
1890
+ * second copy of it is a second place for the abort/pause distinction to be got
1891
+ * wrong. `send` is handed whether this attempt must CLAIM the id as its own —
1892
+ * false the first time, since a fresh id has nothing to resume and saying
1893
+ * otherwise waives the refusal that makes a caller-chosen id safe.
1755
1894
  *
1756
- * @param props - Progress-log props.
1895
+ * Throws whatever `send` threw, except an abort the gate caused. A cancelled gate
1896
+ * throws too: the caller distinguishes it by reading `gate.cancelled`, which is
1897
+ * how an abandoned submission unwinds without being reported as a failure.
1898
+ */
1899
+ async function sendThroughGate(gate, send) {
1900
+ let tried = false;
1901
+ for (;;) {
1902
+ await gate.settle();
1903
+ if (gate.cancelled) throw new Error("Upload cancelled.");
1904
+ const resume = tried;
1905
+ tried = true;
1906
+ try {
1907
+ await send(resume);
1908
+ return;
1909
+ } catch (err) {
1910
+ if (gate.cancelled || !isAbortError(err)) throw err;
1911
+ }
1912
+ }
1913
+ }
1914
+ /**
1915
+ * A gate, open.
1757
1916
  *
1758
- * @public
1917
+ * One per upload rather than one per hook: the id and the windows already stored
1918
+ * belong to a file, so a gate that outlived its file would resume something else.
1759
1919
  */
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
- });
1920
+ function createUploadGate() {
1921
+ let controller = new AbortController();
1922
+ let paused = false;
1923
+ let cancelled = false;
1924
+ let open;
1925
+ let closed;
1926
+ return {
1927
+ get paused() {
1928
+ return paused;
1929
+ },
1930
+ get cancelled() {
1931
+ return cancelled;
1932
+ },
1933
+ get signal() {
1934
+ return controller.signal;
1935
+ },
1936
+ pause() {
1937
+ if (paused || cancelled) return;
1938
+ paused = true;
1939
+ const gate = Promise.withResolvers();
1940
+ closed = gate.promise;
1941
+ open = gate.resolve;
1942
+ controller.abort();
1943
+ },
1944
+ resume() {
1945
+ if (!paused || cancelled) return;
1946
+ paused = false;
1947
+ controller = new AbortController();
1948
+ open?.();
1949
+ open = void 0;
1950
+ closed = void 0;
1951
+ },
1952
+ cancel() {
1953
+ if (cancelled) return;
1954
+ cancelled = true;
1955
+ paused = false;
1956
+ controller.abort();
1957
+ open?.();
1958
+ open = void 0;
1959
+ closed = void 0;
1960
+ },
1961
+ async settle() {
1962
+ if (closed) await closed;
1963
+ }
1964
+ };
1768
1965
  }
1769
1966
  //#endregion
1770
- //#region page.tsx
1771
- /** @jsxImportSource react */
1967
+ //#region src/_workflow-files.ts
1772
1968
  /**
1773
- * `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
1969
+ * Which of a submitted form's values are FILES.
1774
1970
  *
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.
1971
+ * Its own module because both submit hooks need the identical answer and then do
1972
+ * two different things with it — `useWorkflowSubmit` stores each file and passes
1973
+ * its id, `useWorkflowStream` mints the id first so the run can start on it.
1974
+ * A second copy of this predicate would be a form field that one hook treats as a
1975
+ * file and the other does not, which is invisible until the run reads the wrong
1976
+ * kind of string.
1977
+ */
1978
+ /**
1979
+ * The files a submitted field carries, if that is what it carries.
1783
1980
  *
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()`.
1981
+ * An array counts only when it is files ALL the way through — a mixed array is
1982
+ * some other field's value that happens to contain one, and turning half of it
1983
+ * into ids would corrupt it silently.
1788
1984
  */
1985
+ function filesOf(value) {
1986
+ if (value instanceof File) return [value];
1987
+ if (!Array.isArray(value)) return [];
1988
+ const files = value.filter((one) => one instanceof File);
1989
+ return files.length > 0 && files.length === value.length ? files : [];
1990
+ }
1789
1991
  /**
1790
- * Mount a page for an agent whose work happens in workflows.
1992
+ * The input properties still carrying a `File` — i.e. the ones that CANNOT survive
1993
+ * being sent.
1791
1994
  *
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.
1995
+ * A run input is JSON, and `JSON.stringify(new File(…))` is `{}` — no `toJSON`, no
1996
+ * own enumerable properties. So a File left in a payload does not fail to send: it
1997
+ * arrives as an empty object, and the workflow rejects it against whatever its own
1998
+ * schema says the property should be. Measured in production as
1999
+ * `Invalid input for workflow "transcribe": recording: Invalid input` — a message
2000
+ * about a type, on a form where the user had picked a perfectly good file.
1795
2001
  *
1796
- * @example
1797
- * ```tsx
1798
- * import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
1799
- * import { useState } from "react";
2002
+ * Exported beside {@link filesOf} because it is the same question asked at the
2003
+ * other end: that one decides which fields to UPLOAD, this one checks that none
2004
+ * were missed. Both hooks are the callers.
2005
+ */
2006
+ function fileFields(input) {
2007
+ if (!isRecord(input)) return [];
2008
+ return Object.entries(input).filter(([, value]) => filesOf(value).length > 0).map(([key]) => key);
2009
+ }
2010
+ //#endregion
2011
+ //#region src/_upload-files.ts
2012
+ /**
2013
+ * Turning a form's `File`s into stored upload ids, pauses and all.
1800
2014
  *
1801
- * // Hoisted: a client built in render is a new object every render.
1802
- * const api = createWorkflowApi();
2015
+ * Split out of `use-workflow-form.ts` for the 500-line cap, and the seam is a
2016
+ * real one: that module is the two HOOKS and the state between them, where this
2017
+ * is the walk over a submitted input — which is the only part of it that knows
2018
+ * what a `File` is, holds a loop, and survives being re-entered.
1803
2019
  *
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
- * }
2020
+ * `_`-internal. `useWorkflowSubmit` is the only caller; `useWorkflowStream` sends
2021
+ * one file rather than walking an input and shares only the gate underneath both
2022
+ * (`_upload-session.ts`).
2023
+ */
2024
+ /** A fresh session for one submission of `workflow`. */
2025
+ function createUploadSession(workflow) {
2026
+ return {
2027
+ scope: workflow,
2028
+ ids: /* @__PURE__ */ new Map(),
2029
+ stored: /* @__PURE__ */ new Map(),
2030
+ tried: /* @__PURE__ */ new Set(),
2031
+ gate: createUploadGate()
2032
+ };
2033
+ }
2034
+ /**
2035
+ * The id to store this file under: the one a previous page load was using, or a
2036
+ * fresh one.
1816
2037
  *
1817
- * page({ name: "Digest", component: App });
1818
- * ```
2038
+ * **The AGENT decides, never the fingerprint.** Two files can agree on every
2039
+ * field `_upload-recall.ts` keys by, so the recalled id is a candidate that has to
2040
+ * be checked before a byte is sent to it — and the check is cheap and exact,
2041
+ * because `uploadInfo` is the same record a resume already reads.
1819
2042
  *
1820
- * @throws If the target element is not found in the DOM.
2043
+ * Three answers, and the third is why this is not just a storage lookup:
1821
2044
  *
1822
- * @public
2045
+ * - **Complete.** Every byte is in from a load that is gone, so there is nothing
2046
+ * to send: the caller takes the id and starts the run. This is the refresh that
2047
+ * costs one `GET` instead of a second 200 MB upload.
2048
+ * - **Unfinished, with windows.** `UploadInfo.ranges` is what makes an
2049
+ * upload resumable at all, so the id is reused and the attempt claims it.
2050
+ * - **Anything else.** A 404 (swept, or never seen), a failure, or an unfinished
2051
+ * upload reporting NO windows — which is a partial single `PUT`, and a second
2052
+ * `PUT` to that id is a 409 rather than an append (`streamUploadFile`). Reusing
2053
+ * it would turn a reload into a failure the person cannot clear, so the entry is
2054
+ * dropped and the file gets a fresh id.
1823
2055
  */
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)));
2056
+ async function claimId(api, session, file) {
2057
+ const remembered = recallUploadId(session.scope, file);
2058
+ if (remembered === void 0) return {
2059
+ id: randomUploadId(),
2060
+ complete: false
2061
+ };
2062
+ const info = await api.uploadInfo(remembered).catch(() => void 0);
2063
+ if (info?.complete === true) return {
2064
+ id: remembered,
2065
+ complete: true
2066
+ };
2067
+ if (info !== void 0 && (info.ranges?.length ?? 0) > 0) {
2068
+ session.tried.add(file);
2069
+ return {
2070
+ id: remembered,
2071
+ complete: false
2072
+ };
2073
+ }
2074
+ forgetUploadId(session.scope, file);
2075
+ return {
2076
+ id: randomUploadId(),
2077
+ complete: false
2078
+ };
1828
2079
  }
1829
- //#endregion
1830
- //#region use-download-url.ts
1831
2080
  /**
1832
- * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
1833
- * `<img>` or `<a download>` will accept.
2081
+ * Replace every `File` in a submitted form with the id of a stored upload,
2082
+ * reporting how far each one has got.
1834
2083
  *
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:
2084
+ * Sequential rather than `Promise.all`: these are large bodies, and a form with
2085
+ * two 200 MB recordings should send them one after another rather than compete
2086
+ * for the same connection. That is also what makes a single bar honest — one
2087
+ * file is in flight at a time, and `index`/`count` say which.
1840
2088
  *
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.
2089
+ * Anything that is not a `File` (or an array of them) passes through untouched,
2090
+ * so this is invisible to every form that has none — including one whose values
2091
+ * are not an object at all, which `submit` accepts.
1849
2092
  *
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.
2093
+ * ## `uploadStream`, not `upload`, and the id is the reason
2094
+ *
2095
+ * The difference between the two calls is only who mints the id — and that is
2096
+ * exactly what decides whether an interrupted upload can be picked up again. An
2097
+ * `upload` mints its own at the END and hands it back, so a caller whose upload
2098
+ * died has nothing to name what was stored and no choice but to send the file
2099
+ * again. A `uploadStream` is told the id up front, so the windows already in the
2100
+ * store are addressable, which is what both a pause and a server restart need.
2101
+ *
2102
+ * Nothing else about the submission changes: the run is still started after the
2103
+ * last byte lands, so the incomplete record a streamed upload leaves along the
2104
+ * way is one nobody reads.
1854
2105
  */
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 };
2106
+ async function uploadFiles(api, input, report, parallel, session) {
2107
+ if (!isRecord(input)) return input;
2108
+ const entries = Object.entries(input);
2109
+ const count = entries.reduce((total, [, value]) => total + filesOf(value).length, 0);
2110
+ let index = 0;
2111
+ const store = async (file) => {
2112
+ index += 1;
2113
+ const done = session.stored.get(file);
2114
+ if (done !== void 0) return done;
2115
+ const position = {
2116
+ name: file.name,
2117
+ index,
2118
+ count
2119
+ };
2120
+ const known = session.ids.get(file);
2121
+ const claimed = known === void 0 ? await claimId(api, session, file) : {
2122
+ id: known,
2123
+ complete: false
2124
+ };
2125
+ const id = claimed.id;
2126
+ if (known === void 0) {
2127
+ session.ids.set(file, id);
2128
+ rememberUploadId(session.scope, file, id);
2129
+ }
2130
+ if (claimed.complete) {
2131
+ report({
2132
+ ...position,
2133
+ loaded: file.size,
2134
+ total: file.size,
2135
+ fraction: 1,
2136
+ paused: false
2137
+ });
2138
+ session.stored.set(file, id);
2139
+ return id;
2140
+ }
2141
+ await sendThroughGate(session.gate, async (resume) => {
2142
+ await api.uploadStream(id, file, {
2143
+ name: file.name,
2144
+ signal: session.gate.signal,
2145
+ onProgress: (progress) => report({
2146
+ ...position,
2147
+ ...progress,
2148
+ paused: session.gate.paused
2149
+ }),
2150
+ ...omitUndefined({
2151
+ parallel,
2152
+ resume: resume || session.tried.has(file) ? true : void 0
2153
+ })
2154
+ });
2155
+ });
2156
+ session.stored.set(file, id);
2157
+ return id;
2158
+ };
2159
+ const out = {};
2160
+ for (const [name, value] of entries) {
2161
+ if (value instanceof File) {
2162
+ out[name] = await store(value);
2163
+ continue;
2164
+ }
2165
+ const chosen = filesOf(value);
2166
+ if (chosen.length === 0) {
2167
+ out[name] = value;
2168
+ continue;
2169
+ }
2170
+ const ids = [];
2171
+ for (const file of chosen) ids.push(await store(file));
2172
+ out[name] = ids;
2173
+ }
2174
+ return out;
2175
+ }
2176
+ //#endregion
2177
+ //#region src/_upload-report.ts
1859
2178
  /**
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";
2179
+ * What the bar draws, as a comparable string.
1872
2180
  *
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
- * ```
2181
+ * The fraction is quantized to a tenth of a percent — finer than anything the
2182
+ * bar or the byte readout resolves. Everything else is compared exactly:
2183
+ * `paused` is a state change a person is waiting to see, and the name/index
2184
+ * pair moving means a different FILE, which must never be coalesced away.
2185
+ */
2186
+ function renderKey(status) {
2187
+ const { name, index, count, loaded, total, fraction, paused } = status;
2188
+ const shown = fraction === void 0 ? loaded : Math.round(fraction * 1e3);
2189
+ return `${paused} ${index} ${count} ${total ?? -1} ${shown} ${name}`;
2190
+ }
2191
+ /**
2192
+ * Wrap an `UploadStatus` setter so redundant reports never reach React.
1886
2193
  *
1887
- * @public
2194
+ * Clearing the status (`undefined`) always passes and resets the comparison —
2195
+ * it ends one file's bar, and the next file must not be coalesced against the
2196
+ * previous one's last frame.
1888
2197
  */
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);
2198
+ function coalesceUploadReports(set) {
2199
+ let last;
2200
+ return (status) => {
2201
+ if (status === void 0) {
2202
+ last = void 0;
2203
+ set(void 0);
1895
2204
  return;
1896
2205
  }
1897
- let cancelled = false;
1898
- let objectUrl;
1899
- setState(PENDING);
1900
- getClient().download(uploadId).then((blob) => {
1901
- 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
- cancelled = true;
1915
- if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
1916
- };
1917
- }, [uploadId, getClient]);
1918
- return state;
2206
+ const key = renderKey(status);
2207
+ if (key === last) return;
2208
+ last = key;
2209
+ set(status);
2210
+ };
1919
2211
  }
1920
2212
  //#endregion
1921
- //#region use-run-key.ts
2213
+ //#region src/use-workflow-form.ts
1922
2214
  /**
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.
1944
- *
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.
2215
+ * The hook a FORM needs, as against the one a status view does.
1952
2216
  *
1953
- * ## The storage is the caller's decision, and it is not a detail
2217
+ * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
2218
+ * This is what comes before it: `useWorkflowSubmit` starts a run and hands the
2219
+ * id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
2220
+ * `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
1954
2221
  *
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.
2222
+ * ## `useWorkflowSubmit` — a form's two halves in one hook
1960
2223
  *
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.
2224
+ * A page that submits a workflow always needs the same four pieces of state:
2225
+ * the run id, whether a submit is in flight, whether the RUN is still going, and
2226
+ * whichever of the two failed. `link-digest` writes them out by hand, which is
2227
+ * the right shape for a template teaching the primitives and the wrong shape to
2228
+ * write a third time — and it is easy to get subtly wrong: dropping the previous
2229
+ * run id before the new `POST` returns is what stops a finished result sitting
2230
+ * under a form that is already submitting again.
1967
2231
  *
1968
- * ## Anything ELSE a page stores back must be VALIDATED on read
2232
+ * So this is `api.start` plus {@link useWorkflowRun}, with the state between
2233
+ * them. It adds no transport of its own and holds no run state of its own; the
2234
+ * watching (stream first, poll as its fallback, terminal stops) is entirely
2235
+ * `useWorkflowRun`'s, and `run` here IS its run.
1969
2236
  *
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.
2237
+ * ## Why it starts ASYNCHRONOUSLY even though a synchronous call exists
1980
2238
  *
1981
- * ## The slot is keyed by the page's own URL
2239
+ * `api.startAndWait` would collapse this to one request, and it is the wrong
2240
+ * default for a page: it holds a socket open for up to a minute, answers nothing
2241
+ * until it settles, and a page has `useWorkflowRun` — which survives a reload,
2242
+ * shows progress, and costs one stream. The synchronous call is for callers with
2243
+ * nowhere to put a watch (a script, a cron, a form POST from a server). Pass
2244
+ * `wait` here when the page really does want one request, and the run is
2245
+ * followed from the same id either way.
2246
+ */
2247
+ /**
2248
+ * Start a workflow from a form, and follow the run it creates.
1982
2249
  *
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.
2250
+ * @typeParam D - The workflow DEFINITION, which types both halves of the
2251
+ * submission: `submit(input)` takes what the workflow's schema parses to, and
2252
+ * `run.status === "completed"` narrows to a typed `run.output`.
1989
2253
  *
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.
2254
+ * It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
2255
+ * already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
2256
+ * `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
2257
+ * 400 in the browser. Naming the def instead types the input from the same
2258
+ * declaration — and `import type` is ERASED, so it costs the bundle nothing.
2259
+ * Passing an output type where a def belongs is now a compile error rather
2260
+ * than a silent loss of typing, which is the point.
1993
2261
  *
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.
1999
- */
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}`;
2010
- }
2011
- }
2012
- /**
2013
- * Read the key this page already has, or mint and remember one.
2262
+ * @example
2263
+ * ```tsx no-check
2264
+ * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
2265
+ * import type { digest } from "./agent.ts";
2014
2266
  *
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.
2018
- */
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();
2030
- }
2031
- }
2032
- /**
2033
- * A lookup key for `useWorkflowSubmit({ key, recover: true })`, stable across
2034
- * reloads.
2267
+ * function DigestForm() {
2268
+ * const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
2269
+ * return (
2270
+ * <Form onSubmit={(values) => submit(values)} error={error}>
2271
+ * <TextField name="url" label="Link" type="url" required />
2272
+ * <SubmitButton pending={pending}>Digest</SubmitButton>
2273
+ * {run?.status === "completed" && <p>{run.output.title}</p>}
2274
+ * </Form>
2275
+ * );
2276
+ * }
2277
+ * ```
2035
2278
  *
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"`).
2279
+ * @public
2042
2280
  */
2043
- function useRunKey(options = {}) {
2044
- const { storage = "session" } = options;
2045
- const [key] = useState(() => mintRunKey(storage));
2046
- return key;
2281
+ function useWorkflowSubmit(workflow, opts = {}) {
2282
+ const { api, recover = true, wait, intervalMs, parallel } = opts;
2283
+ const key = useDefaultRunKey(opts.key);
2284
+ const state = useSubmissionState();
2285
+ const { runId, actions } = state;
2286
+ const getClient = useWorkflowApiRef(api);
2287
+ const tracked = useWorkflowRun(runId, omitUndefined({
2288
+ api,
2289
+ intervalMs
2290
+ }));
2291
+ const { wake, cancel } = useRunControls(runId, getClient);
2292
+ const [startedHere, setStartedHere] = useState(false);
2293
+ const recovering = useRecoveredRun({
2294
+ workflow,
2295
+ key,
2296
+ enabled: recover,
2297
+ getClient,
2298
+ onFound: (found) => {
2299
+ actions.setRunId((current) => current ?? found);
2300
+ },
2301
+ onError: actions.setStartError
2302
+ });
2303
+ const submit = useCallback(async (input) => {
2304
+ const client = getClient();
2305
+ const current = createUploadSession(workflow);
2306
+ setStartedHere(true);
2307
+ actions.begin(current);
2308
+ try {
2309
+ const options = omitUndefined({ key });
2310
+ const started = await uploadFiles(client, input, coalesceUploadReports(actions.setUpload), parallel, current);
2311
+ actions.setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
2312
+ ...options,
2313
+ wait
2314
+ })).runId);
2315
+ } catch (err) {
2316
+ if (!current.gate.cancelled) actions.setStartError(errorMessage(err));
2317
+ } finally {
2318
+ actions.end(current);
2319
+ }
2320
+ }, [
2321
+ workflow,
2322
+ key,
2323
+ wait,
2324
+ parallel,
2325
+ getClient,
2326
+ actions
2327
+ ]);
2328
+ return {
2329
+ submit,
2330
+ submitForm: submit,
2331
+ reset: useCallback(() => {
2332
+ setStartedHere(false);
2333
+ actions.reset();
2334
+ }, [actions]),
2335
+ wake,
2336
+ cancel,
2337
+ pauseUpload: actions.pauseUpload,
2338
+ resumeUpload: actions.resumeUpload,
2339
+ run: tracked.run,
2340
+ startedHere,
2341
+ pending: recovering || state.starting || tracked.polling,
2342
+ upload: state.upload,
2343
+ error: state.startError ?? tracked.error
2344
+ };
2047
2345
  }
2048
2346
  //#endregion
2049
- //#region use-workflow-runs.ts
2347
+ //#region src/use-workflow-runs.ts
2050
2348
  /**
2051
2349
  * The RUNS a workflow has had — the list a page shows beside its form.
2052
2350
  *
@@ -2144,7 +2442,7 @@ function useWorkflowRuns(workflow, opts = {}) {
2144
2442
  };
2145
2443
  }
2146
2444
  //#endregion
2147
- //#region use-workflow-stream.ts
2445
+ //#region src/use-workflow-stream.ts
2148
2446
  /**
2149
2447
  * Starting a run BEFORE its file has finished uploading.
2150
2448
  *
@@ -2172,7 +2470,7 @@ function useWorkflowRuns(workflow, opts = {}) {
2172
2470
  * earlier version of this hook took a `cut` callback and uploaded N parts into a
2173
2471
  * "group" that a separate call had to seal; it worked, and every piece of it was
2174
2472
  * something the caller had to get right. The whole of that is replaced by the store
2175
- * publishing `size` as bytes land — which `readUpload` already clamped to — so the
2473
+ * publishing `size` as bytes land — which `stepReadUpload` already clamped to — so the
2176
2474
  * run does exactly what it does over a finished file and simply waits for windows to
2177
2475
  * become present.
2178
2476
  *
@@ -2250,25 +2548,21 @@ function useWorkflowRuns(workflow, opts = {}) {
2250
2548
  */
2251
2549
  function useWorkflowStream(workflow, opts = {}) {
2252
2550
  const { api, key, intervalMs, parallel } = opts;
2253
- const [runId, setRunId] = useState(void 0);
2254
- const [starting, setStarting] = useState(false);
2255
- const [startError, setStartError] = useState(void 0);
2256
- const [upload, setUpload] = useState(void 0);
2257
- const gateRef = useRef(void 0);
2551
+ const state = useSubmissionState();
2552
+ const { runId, actions } = state;
2258
2553
  const getClient = useWorkflowApiRef(api);
2259
2554
  const tracked = useWorkflowRun(runId, omitUndefined({
2260
2555
  api,
2261
2556
  intervalMs
2262
2557
  }));
2263
2558
  const { wake, cancel } = useRunControls(runId, getClient);
2559
+ const [startedHere, setStartedHere] = useState(false);
2264
2560
  const submit = useCallback(async (input) => {
2265
2561
  const client = getClient();
2266
- setStarting(true);
2267
- setStartError(void 0);
2268
- setRunId(void 0);
2269
- gateRef.current?.cancel();
2270
- const gate = createUploadGate();
2271
- gateRef.current = gate;
2562
+ const current = { gate: createUploadGate() };
2563
+ const { gate } = current;
2564
+ setStartedHere(true);
2565
+ actions.begin(current);
2272
2566
  let started;
2273
2567
  try {
2274
2568
  const id = randomUploadId();
@@ -2281,7 +2575,7 @@ function useWorkflowStream(workflow, opts = {}) {
2281
2575
  });
2282
2576
  started = begun.runId;
2283
2577
  const chosen = begun.file;
2284
- setRunId(started);
2578
+ actions.setRunId(started);
2285
2579
  if (!chosen) return;
2286
2580
  await streamFile({
2287
2581
  client,
@@ -2289,45 +2583,38 @@ function useWorkflowStream(workflow, opts = {}) {
2289
2583
  id,
2290
2584
  file: chosen,
2291
2585
  parallel,
2292
- report: setUpload
2586
+ report: coalesceUploadReports(actions.setUpload)
2293
2587
  });
2294
2588
  await client.wake(started).catch(() => void 0);
2295
2589
  } catch (err) {
2296
- if (!gate.cancelled) setStartError(errorMessage(err));
2590
+ if (!gate.cancelled) actions.setStartError(errorMessage(err));
2297
2591
  if (started) await client.cancel(started).catch(() => void 0);
2298
2592
  } finally {
2299
- if (gateRef.current === gate) {
2300
- gateRef.current = void 0;
2301
- setStarting(false);
2302
- setUpload(void 0);
2303
- }
2593
+ actions.end(current);
2304
2594
  }
2305
2595
  }, [
2306
2596
  workflow,
2307
2597
  key,
2308
2598
  parallel,
2309
- getClient
2599
+ getClient,
2600
+ actions
2310
2601
  ]);
2311
- const reset = useCallback(() => {
2312
- gateRef.current?.cancel();
2313
- gateRef.current = void 0;
2314
- setRunId(void 0);
2315
- setStartError(void 0);
2316
- setUpload(void 0);
2317
- }, []);
2318
- const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => gateRef.current, []), setUpload);
2319
2602
  return {
2320
2603
  submit,
2321
2604
  submitForm: submit,
2322
- reset,
2605
+ reset: useCallback(() => {
2606
+ setStartedHere(false);
2607
+ actions.reset();
2608
+ }, [actions]),
2323
2609
  wake,
2324
2610
  cancel,
2325
- pauseUpload,
2326
- resumeUpload,
2611
+ pauseUpload: actions.pauseUpload,
2612
+ resumeUpload: actions.resumeUpload,
2327
2613
  run: tracked.run,
2328
- pending: starting || tracked.polling,
2329
- upload,
2330
- error: startError ?? tracked.error
2614
+ startedHere,
2615
+ pending: state.starting || tracked.polling,
2616
+ upload: state.upload,
2617
+ error: state.startError ?? tracked.error
2331
2618
  };
2332
2619
  }
2333
2620
  /**
@@ -2423,7 +2710,7 @@ function fileAt(input, field) {
2423
2710
  return filesOf(input[field])[0];
2424
2711
  }
2425
2712
  //#endregion
2426
- //#region workflow-status-labels.ts
2713
+ //#region src/workflow-status-labels.ts
2427
2714
  /**
2428
2715
  * The default status line per {@link WorkflowRunStatus}.
2429
2716
  *
@@ -2449,4 +2736,4 @@ const WORKFLOW_STATUS_LABELS = {
2449
2736
  cancelled: "Cancelled"
2450
2737
  };
2451
2738
  //#endregion
2452
- export { AutoScroll, Button, ChatView, CheckboxField, ConsoleShell, Controls, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowProgress, client, createSessionCore, createWorkflowApi, fetchClientConfig, isTerminal, page, useAgentState, useConversation, useDownloadUrl, useEvent, useRunKey, useSession, useSessionSelector, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
2739
+ export { AGENT_STATE_LABELS, AutoScroll, BulletList, Button, ChatView, CheckboxField, ConsoleShell, Controls, Facts, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SessionErrorBanner, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowProgress, createBrowserSession, createWorkflowApi, fetchClientConfig, isTerminal, mountClient, mountPage, useAgentState, useConversation, useDownloadUrl, useEvent, useRunKey, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };