@alexkroman1/aai-ui 13.3.0 → 15.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 (82) hide show
  1. package/README.md +159 -69
  2. package/dist/{_colors-CZ6OlPbL.js → _colors-CpZO-88A.js} +24 -1
  3. package/dist/_recover-run.d.ts +2 -2
  4. package/dist/_submission-state.d.ts +92 -0
  5. package/dist/_upload-files.d.ts +2 -2
  6. package/dist/_upload-report.d.ts +26 -0
  7. package/dist/{_utils-CyzjK0gW.js → _utils-DnQDM9Uy.js} +3 -3
  8. package/dist/_utils.d.ts +3 -3
  9. package/dist/_web-storage.d.ts +43 -0
  10. package/dist/_workflow-files.d.ts +1 -1
  11. package/dist/agent-state-labels.d.ts +60 -0
  12. package/dist/audio.js +20 -14
  13. package/dist/{chat-view-Bv5VFJIE.js → chat-view-C_3T7Ln8.js} +96 -29
  14. package/dist/{client-config-DD820zHn.js → client-config-DJQHnYjm.js} +4 -4
  15. package/dist/client-config.d.ts +4 -4
  16. package/dist/client-dir.d.ts +1 -1
  17. package/dist/client-dir.js +1 -1
  18. package/dist/components/_colors.d.ts +23 -0
  19. package/dist/components/_form-readiness.d.ts +1 -1
  20. package/dist/components/bullet-list.d.ts +74 -0
  21. package/dist/components/button.js +3 -3
  22. package/dist/components/chat-view.js +1 -1
  23. package/dist/components/console-shell.d.ts +16 -20
  24. package/dist/components/controls.js +3 -3
  25. package/dist/components/facts.d.ts +81 -0
  26. package/dist/components/form-fields.d.ts +6 -6
  27. package/dist/components/form-types.d.ts +1 -1
  28. package/dist/components/form.d.ts +1 -1
  29. package/dist/components/message-list.js +1 -1
  30. package/dist/components/session-error-banner.d.ts +69 -0
  31. package/dist/components/start-screen.js +1 -1
  32. package/dist/components/tool-call-block.js +1 -1
  33. package/dist/components/tool-config-context.d.ts +1 -1
  34. package/dist/components/workflow-progress.d.ts +11 -4
  35. package/dist/context.d.ts +142 -19
  36. package/dist/context.js +155 -17
  37. package/dist/default-client/assets/{audio-BuDICbPf.js → audio-9zQsNc1w.js} +1 -1
  38. package/dist/default-client/assets/index-BTv30Z4F.css +2 -0
  39. package/dist/default-client/assets/index-BowRD-xq.js +284 -0
  40. package/dist/default-client/index.html +2 -2
  41. package/dist/define-client.d.ts +19 -19
  42. package/dist/define-client.js +19 -19
  43. package/dist/hooks.d.ts +44 -8
  44. package/dist/hooks.js +19 -13
  45. package/dist/index.d.ts +14 -7
  46. package/dist/index.js +551 -231
  47. package/dist/internal.d.ts +2 -2
  48. package/dist/internal.js +5 -5
  49. package/dist/{message-list-C0pL7x41.js → message-list-CdOnSh5m.js} +19 -12
  50. package/dist/page.d.ts +11 -11
  51. package/dist/session-core-audio-setup.d.ts +1 -1
  52. package/dist/session-core-dial.d.ts +0 -2
  53. package/dist/{session-core-C9elBIdu.js → session-core-gwePM95B.js} +125 -52
  54. package/dist/session-core-messages.d.ts +2 -2
  55. package/dist/session-core-types.d.ts +58 -1
  56. package/dist/session-core.d.ts +6 -6
  57. package/dist/session-core.js +2 -2
  58. package/dist/session-resume-store.d.ts +3 -3
  59. package/dist/{tool-call-block-Bunc6rCw.js → tool-call-block-C2t_5fpp.js} +27 -9
  60. package/dist/{tool-config-context-Bh8p3DtG.js → tool-config-context-Es4YUzV2.js} +1 -1
  61. package/dist/types.d.ts +19 -4
  62. package/dist/types.js +2 -2
  63. package/dist/url-chips-D62RTz7v.js +239 -0
  64. package/dist/use-conversation.d.ts +1 -1
  65. package/dist/use-copy.d.ts +74 -0
  66. package/dist/use-download-url.d.ts +2 -2
  67. package/dist/use-flash.d.ts +61 -0
  68. package/dist/{use-user-transcript-DFTSEuZN.js → use-user-transcript-uyHhzy4d.js} +3 -2
  69. package/dist/use-workflow-form.d.ts +35 -3
  70. package/dist/use-workflow-progress.d.ts +1 -1
  71. package/dist/{use-workflow-run-CXGEcM0l.js → use-workflow-run-CNahvlgL.js} +12 -15
  72. package/dist/use-workflow-run.d.ts +2 -2
  73. package/dist/use-workflow-runs.d.ts +1 -1
  74. package/dist/use-workflow-stream.d.ts +2 -2
  75. package/dist/use-workflows.d.ts +2 -2
  76. package/dist/workflow-client.d.ts +3 -3
  77. package/dist/workflow-field-kind.d.ts +95 -0
  78. package/package.json +2 -2
  79. package/styles.css +78 -0
  80. package/dist/default-client/assets/index-B1_ROnTJ.js +0 -284
  81. package/dist/default-client/assets/index-S5fkKi6B.css +0 -2
  82. package/dist/url-chips-C2u7QPv8.js +0 -100
package/dist/index.js CHANGED
@@ -1,25 +1,190 @@
1
- import { n as fetchClientConfig } from "./client-config-DD820zHn.js";
2
- import { i as AutoScroll, n as Markdown, r as useConversation, t as MessageList } from "./message-list-C0pL7x41.js";
3
- import { ThemeProvider, useSession, useSessionSelector, useTheme } from "./context.js";
4
- import { r as inkTint } from "./_colors-CZ6OlPbL.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-Bv5VFJIE.js";
7
- import { n as setPageTitle } from "./_utils-CyzjK0gW.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
+ import { a as useFlash, i as useCopy } from "./url-chips-D62RTz7v.js";
8
9
  import { Controls } from "./components/controls.js";
9
- import { n as useUserTranscript } from "./use-user-transcript-DFTSEuZN.js";
10
- import { n as ToolCallRow } from "./tool-call-block-Bunc6rCw.js";
10
+ import { n as useUserTranscript } from "./use-user-transcript-uyHhzy4d.js";
11
+ import { n as ToolCallRow } from "./tool-call-block-C2t_5fpp.js";
11
12
  import { SidebarLayout } from "./components/sidebar-layout.js";
12
13
  import { StartScreen } from "./components/start-screen.js";
13
- import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CXGEcM0l.js";
14
- import { t as createSessionCore } from "./session-core-C9elBIdu.js";
15
- import { client, mountRoot, resolveContainer } from "./define-client.js";
14
+ import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CNahvlgL.js";
15
+ import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-gwePM95B.js";
16
+ import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
16
17
  import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
17
18
  import clsx from "clsx";
18
19
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
19
- import { createContext, createElement, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
20
+ import { createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
20
21
  import { errorMessage } from "@alexkroman1/aai";
21
22
  import { formatBytes, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
22
23
  import { createEpoch } from "@alexkroman1/aai/internal";
24
+ //#region src/agent-state-labels.ts
25
+ /**
26
+ * The default label per {@link AgentState}.
27
+ *
28
+ * Override the ones your page has a better word for and keep the rest:
29
+ *
30
+ * ```ts
31
+ * import type { AgentState } from "@alexkroman1/aai-ui";
32
+ * import { AGENT_STATE_LABELS } from "@alexkroman1/aai-ui";
33
+ *
34
+ * // A dispatch board that shouts, and renames one state.
35
+ * const STATE_LABEL = { ...AGENT_STATE_LABELS, thinking: "Processing" };
36
+ * const shout = (s: AgentState) => STATE_LABEL[s].toUpperCase();
37
+ * ```
38
+ *
39
+ * **Sentence case, deliberately.** A template that wants caps applies its own
40
+ * `.toUpperCase()`, and a template that wants Title Case is already there;
41
+ * shipping the shouted form instead would leave the two chromes that do not
42
+ * shout with a string they have to un-shout, which no case transform does
43
+ * correctly.
44
+ *
45
+ * Two wordings are decisions rather than transliterations of the member name:
46
+ *
47
+ * - `disconnected` is **"Idle"**. It is the state a session is in BEFORE it has
48
+ * ever started as well as after it ends, so it is the first word most callers
49
+ * see; "Disconnected" reads as a fault on a page where nothing has gone
50
+ * wrong yet. Both chromes that mapped this state by hand chose "Idle" too.
51
+ * - `connecting` and `thinking` carry an ellipsis, `listening` and `speaking`
52
+ * do not. The first two are waits with nothing for the caller to do; the
53
+ * other two describe someone actually talking. Same distinction
54
+ * `WORKFLOW_STATUS_LABELS` draws with its one "Working…".
55
+ *
56
+ * @public
57
+ */
58
+ const AGENT_STATE_LABELS = {
59
+ disconnected: "Idle",
60
+ connecting: "Connecting…",
61
+ ready: "Ready",
62
+ listening: "Listening",
63
+ thinking: "Thinking…",
64
+ speaking: "Speaking",
65
+ error: "Error"
66
+ };
67
+ //#endregion
68
+ //#region src/components/bullet-list.tsx
69
+ /** @jsxImportSource react */
70
+ /**
71
+ * A disc-bulleted list of short strings — a run's key points, findings, risks.
72
+ *
73
+ * Five pages had written this, byte-identical apart from a `text-sm` suffix on
74
+ * two of them, and all five had the same two defects. Both are the reason this
75
+ * is a component rather than four lines a page repeats:
76
+ *
77
+ * - **All five keyed by the string itself, and these lists are MODEL OUTPUT.**
78
+ * Two identical bullets are entirely plausible — a summariser that repeats
79
+ * itself is a bad summary, not a bad program — and a repeated string is then
80
+ * a duplicate `key`: React warns, and the two `<li>`s contend for one slot in
81
+ * the reconciliation. What is keyed here instead is the content PLUS how many
82
+ * times that content has already appeared in this list, which is unique by
83
+ * construction and unchanged by a re-render that did not change the text.
84
+ * (Position alone would also be sound — these lists are replaced wholesale by
85
+ * each new output and never reordered — but it is what `noArrayIndexKey`
86
+ * exists to talk you out of, and a unique key is one `Map` away, so there is
87
+ * no reason to spend a lint suppression on it.)
88
+ * - **Three of the five rendered an empty `<ul>` under a heading.** Two had
89
+ * hand-rolled `if (items.length === 0) return null` and three had not, so the
90
+ * same absent field was "nothing" on two pages and a stray heading with a
91
+ * void under it on three. Emptiness renders NOTHING here, `title` included:
92
+ * a heading over no bullets is a claim the run did not make.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * import { BulletList } from "@alexkroman1/aai-ui";
97
+ *
98
+ * function Findings({ risks }: { risks: string[] }) {
99
+ * return <BulletList title="Risks" items={risks} size="sm" />;
100
+ * }
101
+ * ```
102
+ *
103
+ * @param props - Bullet-list props.
104
+ *
105
+ * @public
106
+ */
107
+ function BulletList({ items, title, size = "base", className }) {
108
+ if (items.length === 0) return null;
109
+ const seen = /* @__PURE__ */ new Map();
110
+ const list = /* @__PURE__ */ jsx("ul", {
111
+ className: clsx("flex list-disc flex-col gap-1 pl-5", size === "sm" && "text-sm", className),
112
+ children: items.map((item) => {
113
+ const nth = seen.get(item) ?? 0;
114
+ seen.set(item, nth + 1);
115
+ return /* @__PURE__ */ jsx("li", { children: item }, `${nth}:${item}`);
116
+ })
117
+ });
118
+ const heading = title === void 0 || title === null || title === false ? void 0 : title;
119
+ if (heading === void 0) return list;
120
+ return /* @__PURE__ */ jsxs("section", {
121
+ className: "flex flex-col gap-1",
122
+ children: [/* @__PURE__ */ jsx("h3", {
123
+ className: "text-sm font-medium opacity-70",
124
+ children: heading
125
+ }), list]
126
+ });
127
+ }
128
+ //#endregion
129
+ //#region src/components/facts.tsx
130
+ /** @jsxImportSource react */
131
+ /** What goes between two facts. A separator, not a bullet — U+00B7, not U+2022. */
132
+ const FACT_SEPARATOR = " · ";
133
+ /**
134
+ * A muted line of run facts, joined by `·` — "6 segments · 12:04 of audio ·
135
+ * 1,840 words".
136
+ *
137
+ * Nine pages had written this by hand under four different typographies for
138
+ * one role, two of them (`call-audit` and `spoken-summary`) byte-identical down
139
+ * to the payload. Three things it takes off the caller:
140
+ *
141
+ * - **The separator cannot be forgotten, and neither can the space around it.**
142
+ * Four of the nine carried a literal `{" "}` at the end of a line, because
143
+ * Prettier's wrap ate the space that made `· ` read as a separator rather
144
+ * than as punctuation glued to the next word. A line that is correct only
145
+ * because somebody remembered an invisible JSX expression is exactly the
146
+ * thing a component should own.
147
+ * - **A fact worth omitting is omitted, and by the caller's own condition.**
148
+ * The hand-written shape for a conditional fact was to splice the separator
149
+ * into the string — `{x ? \` · budget exhausted\` : ""}` — which puts the
150
+ * punctuation in two places and gets the leading separator wrong the moment
151
+ * the fact before it also disappears. Passing the condition and letting this
152
+ * drop it keeps the separator in one place.
153
+ * - **A line with nothing left to say renders NOTHING.** With every fact
154
+ * conditional, the alternative is a muted empty row, or a bare `·`.
155
+ *
156
+ * The facts are JOINED into one string rather than interleaved as elements, and
157
+ * that is why the prop is text: joined, there is no per-fact `key` to invent —
158
+ * the same reasoning `WorkflowProgress` gives for its log lines. A line that
159
+ * genuinely needs an element in it (a link) wants its own markup.
160
+ *
161
+ * @example
162
+ * ```tsx
163
+ * import { Facts } from "@alexkroman1/aai-ui";
164
+ *
165
+ * function RunFacts({ words, cut }: { words: number; cut: number }) {
166
+ * return <Facts size="xs" items={[`${words} words`, cut > 0 && `${cut} blind cuts`]} />;
167
+ * }
168
+ * ```
169
+ *
170
+ * @param props - Facts-line props.
171
+ *
172
+ * @public
173
+ */
174
+ function Facts({ items, size = "sm", as = "p", className }) {
175
+ const shown = items.filter((item) => item !== false && item !== null && item !== void 0 && item !== "");
176
+ if (shown.length === 0) return null;
177
+ const text = shown.join(FACT_SEPARATOR);
178
+ const classes = clsx(size === "xs" ? "text-xs opacity-60" : "text-sm opacity-70", className);
179
+ return as === "span" ? /* @__PURE__ */ jsx("span", {
180
+ className: classes,
181
+ children: text
182
+ }) : /* @__PURE__ */ jsx("p", {
183
+ className: classes,
184
+ children: text
185
+ });
186
+ }
187
+ //#endregion
23
188
  //#region src/components/_form-readiness.ts
24
189
  /**
25
190
  * Whether a form's own fields exist yet.
@@ -47,7 +212,7 @@ import { createEpoch } from "@alexkroman1/aai/internal";
47
212
  * one describes an element that EXISTS. So this is a context rather than an
48
213
  * attribute, and it carries the one fact a DOM read cannot recover.
49
214
  *
50
- * A form with no such children is ready by definition — `useFormFieldsPending`
215
+ * A form with no such children is ready by definition — `useFormReadiness`
51
216
  * outside a provider reports nothing pending, so every hand-written form is
52
217
  * unaffected and `Form` keeps working outside this package.
53
218
  */
@@ -173,7 +338,7 @@ function readFiles(files, read) {
173
338
  if (read === "upload") return Promise.resolve([...files]);
174
339
  return Promise.all(files.map((file) => describeFile(file, read)));
175
340
  }
176
- /** The `data-aai-read` attribute as a {@link FileRead}, defaulting to `"none"`. */
341
+ /** The `data-aai-read` attribute as a {@link FileReadMode}, defaulting to `"none"`. */
177
342
  function readMode(raw) {
178
343
  return raw === "text" || raw === "dataUrl" || raw === "upload" ? raw : "none";
179
344
  }
@@ -274,12 +439,12 @@ function Field({ label, hint, htmlFor, className, children }) {
274
439
  function useControlProps() {
275
440
  const theme = useTheme();
276
441
  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"),
442
+ 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
443
  style: {
279
444
  background: theme.surface,
280
445
  color: theme.text,
281
446
  borderColor: theme.border,
282
- outlineColor: theme.primary
447
+ ...focusRingStyle(theme.primary)
283
448
  }
284
449
  };
285
450
  }
@@ -311,16 +476,14 @@ function useFileControlProps() {
311
476
  };
312
477
  }
313
478
  /**
314
- * A single-line text input.
479
+ * `TextField` and `NumberField`, which differ only in the `type` they set.
315
480
  *
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.
320
- *
321
- * @public
481
+ * Internal, because the two stay separate PUBLIC exports: each is named in the
482
+ * `components` capability contract and in the API report, and a caller writes
483
+ * the one whose value shape it wants — `NumberField` is the one that
484
+ * contributes a number to {@link FormValues}.
322
485
  */
323
- function TextField({ name, label, hint, className, ...rest }) {
486
+ function InputField({ defaultType, name, label, hint, className, ...rest }) {
324
487
  const id = useId();
325
488
  const control = useControlProps();
326
489
  return /* @__PURE__ */ jsx(Field, {
@@ -331,13 +494,29 @@ function TextField({ name, label, hint, className, ...rest }) {
331
494
  children: /* @__PURE__ */ jsx("input", {
332
495
  id,
333
496
  name,
334
- type: "text",
497
+ type: defaultType,
335
498
  ...control,
336
499
  ...rest
337
500
  })
338
501
  });
339
502
  }
340
503
  /**
504
+ * A single-line text input.
505
+ *
506
+ * Accepts every `<input>` attribute except `name` and `className`, which this
507
+ * component owns, plus the shared {@link FieldShell} props.
508
+ *
509
+ * @param props - {@link FieldShell} props plus `<input>` attributes.
510
+ *
511
+ * @public
512
+ */
513
+ function TextField(props) {
514
+ return /* @__PURE__ */ jsx(InputField, {
515
+ defaultType: "text",
516
+ ...props
517
+ });
518
+ }
519
+ /**
341
520
  * A number input. Contributes a NUMBER to {@link FormValues}, or nothing when
342
521
  * left empty.
343
522
  *
@@ -349,21 +528,10 @@ function TextField({ name, label, hint, className, ...rest }) {
349
528
  *
350
529
  * @public
351
530
  */
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
- })
531
+ function NumberField(props) {
532
+ return /* @__PURE__ */ jsx(InputField, {
533
+ defaultType: "number",
534
+ ...props
367
535
  });
368
536
  }
369
537
  /**
@@ -474,14 +642,14 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
474
642
  * travel in it. With `upload` the field contributes the `File` itself,
475
643
  * `useWorkflowSubmit` stores it through `POST /workflows/uploads` before
476
644
  * 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
645
+ * windows of with `stepReadUpload`. Declaring the property in the workflow's
478
646
  * `uploads` list makes `<WorkflowFields>` render exactly this, so a declared
479
647
  * form needs no file markup at all.
480
648
  *
481
649
  * **Without it the field describes the file and does not read it.** `read`
482
650
  * exists for the cases where the bytes really are small and really are the
483
651
  * 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
652
+ * {@link FileReadMode} for the four values; `upload` is shorthand for
485
653
  * `read="upload"`.
486
654
  *
487
655
  * Otherwise accepts every `<input>` attribute except `name`, `className` and
@@ -812,14 +980,14 @@ function UploadProgressBar({ upload, onPause, onResume, className }) {
812
980
  * }
813
981
  * ```
814
982
  *
815
- * @param opts - See {@link UseWorkflowsOptions}.
983
+ * @param options - See {@link UseWorkflowsOptions}.
816
984
  * @returns The listing, its loading flag and its failure — see
817
985
  * {@link UseWorkflowsResult}.
818
986
  *
819
987
  * @public
820
988
  */
821
- function useWorkflows(opts = {}) {
822
- const { api, skip = false } = opts;
989
+ function useWorkflows(options = {}) {
990
+ const { api, skip = false } = options;
823
991
  const [state, setState] = useState({
824
992
  workflows: [],
825
993
  loading: !skip,
@@ -850,6 +1018,100 @@ function useWorkflows(opts = {}) {
850
1018
  return state;
851
1019
  }
852
1020
  //#endregion
1021
+ //#region src/workflow-field-kind.ts
1022
+ /**
1023
+ * Which control one property of a workflow's input schema renders as.
1024
+ *
1025
+ * `<WorkflowFields>` walks a workflow's `inputSchema` and picks a control per
1026
+ * property. This is that decision with nothing rendered — ONE function, called
1027
+ * by `SchemaField` itself, so a reader outside the component can ask the same
1028
+ * question and get the same answer.
1029
+ *
1030
+ * **Why it is published.** The rule is not derivable from the schema by
1031
+ * inspection: an upload property IS a plain `string` there, and the ORDER
1032
+ * matters in a way no reader can see. The studio's API pane documents the
1033
+ * form-to-JSON correspondence for every workflow app, and it was carrying a
1034
+ * hand-kept mirror of the switch below — "a declared upload beats everything,
1035
+ * then `enum`, then …" — with a comment admitting as much and a test pinning
1036
+ * the order to catch the drift. A copy that drifted would name a control the
1037
+ * reader cannot find on their own page. Nothing else could be done about it
1038
+ * while the decision was private, and the same reasoning already settled the
1039
+ * studio's SDK-subpath list: mirroring "would just move the drift somewhere
1040
+ * new".
1041
+ *
1042
+ * So a NEW control added to `<WorkflowFields>` is a new member of
1043
+ * {@link WorkflowFieldKind}, and every reader of the rule finds out from the
1044
+ * type rather than from a table somebody remembered to edit.
1045
+ */
1046
+ /**
1047
+ * A property's declared type, taking the first non-null member of a union.
1048
+ *
1049
+ * `["string", "null"]` is how an optional-and-nullable field converts; the
1050
+ * control is the one the non-null half wants. Not exported from the package
1051
+ * barrel — the published question is the KIND, and this is the one detail
1052
+ * `<WorkflowFields>` needs past it (an integer's `step`).
1053
+ */
1054
+ function schemaTypeOf(schema) {
1055
+ if (!isRecord(schema)) return void 0;
1056
+ const { type } = schema;
1057
+ if (typeof type === "string") return type;
1058
+ return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
1059
+ }
1060
+ /**
1061
+ * Which control `<WorkflowFields>` renders for one property of an input schema.
1062
+ *
1063
+ * @remarks
1064
+ * The ORDER is the contract, and it is the half a reader cannot infer:
1065
+ *
1066
+ * 1. **A declared upload wins outright.** It is a plain `string` in the schema
1067
+ * — the id — so testing the type first would render a text box asking a
1068
+ * person to type an id no person has. The declaration is the workflow's
1069
+ * (`workflow({ uploads: [...] })`), not the schema's, precisely because a
1070
+ * marker inside the schema would only work for the library that carried it.
1071
+ * 2. **Then a non-empty `enum`**, before the type switch: an enum of strings
1072
+ * is a `string` too, and a select is the narrower, better control.
1073
+ * 3. **Then the type** — `boolean`, `number`/`integer`, `string`.
1074
+ * 4. **Anything else is `"none"`** rather than a guess. See
1075
+ * {@link WorkflowFieldKind}.
1076
+ *
1077
+ * @example
1078
+ * ```ts
1079
+ * import { fieldKindFor, type WorkflowFieldKind } from "@alexkroman1/aai-ui";
1080
+ * import type { WorkflowSummary } from "@alexkroman1/aai/workflow-api";
1081
+ *
1082
+ * function kindsOf(summary: WorkflowSummary): Record<string, WorkflowFieldKind> {
1083
+ * const uploads = new Set(summary.uploads ?? []);
1084
+ * const schema = summary.inputSchema as { properties?: Record<string, unknown> };
1085
+ * return Object.fromEntries(
1086
+ * Object.entries(schema.properties ?? {}).map(([name, property]) => [
1087
+ * name,
1088
+ * fieldKindFor(property, { upload: uploads.has(name) }),
1089
+ * ]),
1090
+ * );
1091
+ * }
1092
+ * ```
1093
+ *
1094
+ * @param schema - The property's JSON Schema, as `GET workflows` reports it.
1095
+ * Anything that is not an object reads as `"none"`.
1096
+ * @param options - `upload` says the property is named in the workflow's own
1097
+ * `uploads` declaration, which is what step 1 above tests.
1098
+ *
1099
+ * @public
1100
+ */
1101
+ function fieldKindFor(schema, options) {
1102
+ if (options?.upload === true) return "file";
1103
+ if (!isRecord(schema)) return "none";
1104
+ const { enum: choices } = schema;
1105
+ if (Array.isArray(choices) && choices.length > 0) return "select";
1106
+ switch (schemaTypeOf(schema)) {
1107
+ case "boolean": return "checkbox";
1108
+ case "number":
1109
+ case "integer": return "number";
1110
+ case "string": return "text";
1111
+ default: return "none";
1112
+ }
1113
+ }
1114
+ //#endregion
853
1115
  //#region src/components/workflow-fields.tsx
854
1116
  /** @jsxImportSource react */
855
1117
  /**
@@ -927,39 +1189,37 @@ function SchemaField({ name, property, required, upload = false }) {
927
1189
  const label = humanize(name);
928
1190
  const hint = property.description === void 0 ? {} : { hint: property.description };
929
1191
  const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
930
- if (upload) return /* @__PURE__ */ jsx(FileField, {
931
- name,
932
- label,
933
- required,
934
- upload: true,
935
- ...hint
936
- });
937
- if (Array.isArray(property.enum) && property.enum.length > 0) return /* @__PURE__ */ jsx(SelectField, {
938
- name,
939
- label,
940
- required,
941
- options: property.enum.map((value) => String(value)),
942
- ...hint,
943
- ...defaults
944
- });
945
- const type = typeOf(property);
946
- switch (type) {
947
- case "boolean": return /* @__PURE__ */ jsx(CheckboxField, {
1192
+ switch (fieldKindFor(property, { upload })) {
1193
+ case "file": return /* @__PURE__ */ jsx(FileField, {
1194
+ name,
1195
+ label,
1196
+ required,
1197
+ upload: true,
1198
+ ...hint
1199
+ });
1200
+ case "select": return /* @__PURE__ */ jsx(SelectField, {
1201
+ name,
1202
+ label,
1203
+ required,
1204
+ options: (property.enum ?? []).map((value) => String(value)),
1205
+ ...hint,
1206
+ ...defaults
1207
+ });
1208
+ case "checkbox": return /* @__PURE__ */ jsx(CheckboxField, {
948
1209
  name,
949
1210
  label,
950
1211
  defaultChecked: property.default === true,
951
1212
  ...hint
952
1213
  });
953
- case "number":
954
- case "integer": return /* @__PURE__ */ jsx(NumberField, {
1214
+ case "number": return /* @__PURE__ */ jsx(NumberField, {
955
1215
  name,
956
1216
  label,
957
1217
  required,
958
- step: type === "integer" ? 1 : "any",
1218
+ step: schemaTypeOf(property) === "integer" ? 1 : "any",
959
1219
  ...hint,
960
1220
  ...defaults
961
1221
  });
962
- case "string": return /* @__PURE__ */ jsx(TextField, {
1222
+ case "text": return /* @__PURE__ */ jsx(TextField, {
963
1223
  name,
964
1224
  label,
965
1225
  required,
@@ -969,12 +1229,6 @@ function SchemaField({ name, property, required, upload = false }) {
969
1229
  default: return null;
970
1230
  }
971
1231
  }
972
- /** A property's type, taking the first non-null member of a union. */
973
- function typeOf(property) {
974
- const { type } = property;
975
- if (typeof type === "string") return type;
976
- return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
977
- }
978
1232
  /** The listing's `unknown` schema as the object shape this reads, when it is one. */
979
1233
  function asObjectSchema(schema) {
980
1234
  return isRecord(schema) ? schema : void 0;
@@ -997,13 +1251,13 @@ function humanize(name) {
997
1251
  *
998
1252
  * The complement of a status line, and the reason both exist: a run is
999
1253
  * `running` for its whole life, so a one-round job and a ten-round one look
1000
- * identical while they happen. These lines come from the run itself (`report()`
1254
+ * identical while they happen. These lines come from the run itself (`stepReport()`
1001
1255
  * in a `"use step"` body), which is the only channel a workflow has before it
1002
1256
  * produces an output.
1003
1257
  *
1004
- * Three rules are baked in, and they are why this is a component rather than
1258
+ * Four rules are baked in, and they are why this is a component rather than
1005
1259
  * three lines each page writes for itself — the two templates that had written
1006
- * it had written all three, comments included:
1260
+ * it had written three of them, comments included:
1007
1261
  *
1008
1262
  * - **It renders nothing until there is something to render.** `supported` is
1009
1263
  * what keeps this from being an empty box forever on an agent deployed before
@@ -1017,6 +1271,13 @@ function humanize(name) {
1017
1271
  * or opening a finished run tomorrow — shows how it got there rather than an
1018
1272
  * empty box. That is `useWorkflowProgress`'s doing; this is what makes it
1019
1273
  * visible.
1274
+ * - **They are ANNOUNCED**, for the reason the first paragraph gives: this is
1275
+ * the only channel a run has before it produces an output, and a `<pre>` that
1276
+ * grows is a silent one. A screen-reader user pressing "Digest" got nothing
1277
+ * between the click and a terminal state minutes later — no "fetching", no
1278
+ * "summarising", no evidence the button did anything. See `role="log"` below.
1279
+ * The six pages that render this pass only `className`, so no template could
1280
+ * have fixed it locally; that is what makes it this component's job.
1020
1281
  *
1021
1282
  * @example
1022
1283
  * ```tsx
@@ -1033,27 +1294,33 @@ function humanize(name) {
1033
1294
  */
1034
1295
  function WorkflowProgress({ runId, api, className, placeholder, lines }) {
1035
1296
  const { progress, streaming, supported } = useWorkflowProgress(runId, omitUndefined({ api }));
1036
- const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
1037
- if (!supported || shown.length === 0) return placeholder ?? null;
1297
+ const text = useMemo(() => {
1298
+ const shown = lines === void 0 ? progress : progress.slice(Math.max(progress.length - lines, 0));
1299
+ return shown.length === 0 ? null : shown.join("\n");
1300
+ }, [progress, lines]);
1301
+ if (!supported || text === null) return placeholder ?? null;
1038
1302
  return /* @__PURE__ */ jsxs("pre", {
1303
+ role: "log",
1304
+ "aria-live": "polite",
1305
+ "aria-atomic": "false",
1039
1306
  className: clsx(className ?? "whitespace-pre-wrap border-l pl-4 text-xs opacity-70"),
1040
- children: [shown.join("\n"), streaming && "\n…"]
1307
+ children: [text, streaming && "\n…"]
1041
1308
  });
1042
1309
  }
1043
1310
  //#endregion
1044
1311
  //#region src/page.tsx
1045
1312
  /** @jsxImportSource react */
1046
1313
  /**
1047
- * `page()` — mount a WORKFLOW APP's UI: React, theme, no session.
1314
+ * `mountPage()` — mount a WORKFLOW APP's UI: React, theme, no session.
1048
1315
  *
1049
- * The twin of `client()` for an agent whose front door is a form rather than a
1316
+ * The twin of `mountClient()` for an agent whose front door is a form rather than a
1050
1317
  * microphone (`workflowApp()`). It is a separate entry rather than
1051
- * an option on `client()` because of what `client()` unavoidably does: it
1052
- * constructs a `SessionCore`, which owns a WebSocket URL provider, an audio
1318
+ * an option on `mountClient()` because of what `mountClient()` unavoidably does: it
1319
+ * constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
1053
1320
  * graph, and a microphone request. A flag would have to make all of that
1054
1321
  * conditional, and every session hook would then have to answer "what does this
1055
1322
  * mean with no session?" — so the honest split is two mounts. A page that wants
1056
- * voice uses `client()`; a page that wants neither audio nor a socket uses this.
1323
+ * voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
1057
1324
  *
1058
1325
  * Authoring is otherwise identical — the file is still `client.tsx`, still
1059
1326
  * React, still Tailwind, still the same theme tokens — so a workflow app reads
@@ -1069,7 +1336,7 @@ function WorkflowProgress({ runId, api, className, placeholder, lines }) {
1069
1336
  *
1070
1337
  * @example
1071
1338
  * ```tsx
1072
- * import { createWorkflowApi, page, useWorkflowRun } from "@alexkroman1/aai-ui";
1339
+ * import { createWorkflowApi, mountPage, useWorkflowRun } from "@alexkroman1/aai-ui";
1073
1340
  * import { useState } from "react";
1074
1341
  *
1075
1342
  * // Hoisted: a client built in render is a new object every render.
@@ -1088,14 +1355,14 @@ function WorkflowProgress({ runId, api, className, placeholder, lines }) {
1088
1355
  * );
1089
1356
  * }
1090
1357
  *
1091
- * page({ name: "Digest", component: App });
1358
+ * mountPage({ name: "Digest", component: App });
1092
1359
  * ```
1093
1360
  *
1094
1361
  * @throws If the target element is not found in the DOM.
1095
1362
  *
1096
1363
  * @public
1097
1364
  */
1098
- function page(config) {
1365
+ function mountPage(config) {
1099
1366
  const container = resolveContainer(config.target);
1100
1367
  setPageTitle(config.name);
1101
1368
  return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
@@ -1136,7 +1403,7 @@ const PENDING = { pending: true };
1136
1403
  * @param uploadId - The id a completed run reported, or `undefined` before one
1137
1404
  * exists — which is what a page passes straight through while it waits, and
1138
1405
  * reports as idle rather than pending.
1139
- * @param opts - See {@link UseDownloadUrlOptions}.
1406
+ * @param options - See {@link UseDownloadUrlOptions}.
1140
1407
  * @returns See {@link UseDownloadUrlResult}.
1141
1408
  *
1142
1409
  * @example
@@ -1160,9 +1427,9 @@ const PENDING = { pending: true };
1160
1427
  *
1161
1428
  * @public
1162
1429
  */
1163
- function useDownloadUrl(uploadId, opts = {}) {
1430
+ function useDownloadUrl(uploadId, options = {}) {
1164
1431
  const [state, setState] = useState(IDLE);
1165
- const getClient = useWorkflowApiRef(opts.api);
1432
+ const getClient = useWorkflowApiRef(options.api);
1166
1433
  useEffect(() => {
1167
1434
  if (uploadId === void 0) {
1168
1435
  setState(IDLE);
@@ -1286,13 +1553,7 @@ function useDownloadUrl(uploadId, opts = {}) {
1286
1553
  const PREFIX$1 = "aai:run-key:";
1287
1554
  /** This page's own slot — see "The slot is keyed by the page's own URL". */
1288
1555
  function slotFor() {
1289
- const href = globalThis.location?.href;
1290
- if (href === void 0) return PREFIX$1;
1291
- try {
1292
- return `${PREFIX$1}${new URL("./", href).href}`;
1293
- } catch {
1294
- return `${PREFIX$1}${href}`;
1295
- }
1556
+ return urlSlot(PREFIX$1, "./");
1296
1557
  }
1297
1558
  /**
1298
1559
  * Read the key this page already has, or mint and remember one.
@@ -1302,17 +1563,12 @@ function slotFor() {
1302
1563
  * hand `recover` one nothing was ever started under.
1303
1564
  */
1304
1565
  function mintRunKey(storage) {
1305
- try {
1306
- const store = storage === "local" ? globalThis.localStorage : globalThis.sessionStorage;
1307
- const slot = slotFor();
1308
- const stored = store?.getItem(slot);
1309
- if (stored !== null && stored !== void 0) return stored;
1310
- const minted = crypto.randomUUID();
1311
- store?.setItem(slot, minted);
1312
- return minted;
1313
- } catch {
1314
- return crypto.randomUUID();
1315
- }
1566
+ const slot = slotFor();
1567
+ const stored = storageGet(storage, slot);
1568
+ if (stored !== void 0) return stored;
1569
+ const minted = crypto.randomUUID();
1570
+ storageSet(storage, slot, minted);
1571
+ return minted;
1316
1572
  }
1317
1573
  /**
1318
1574
  * The key `useWorkflowSubmit` uses when the page named none.
@@ -1338,7 +1594,7 @@ function mintRunKey(storage) {
1338
1594
  * @internal
1339
1595
  */
1340
1596
  function useDefaultRunKey(explicit) {
1341
- const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : crypto.randomUUID());
1597
+ const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
1342
1598
  return explicit ?? minted;
1343
1599
  }
1344
1600
  /**
@@ -1422,11 +1678,11 @@ function useRunKey(options = {}) {
1422
1678
  */
1423
1679
  function useRecoveredRun(opts) {
1424
1680
  const { workflow, key, enabled, getClient } = opts;
1425
- const [recovering, setRecovering] = useState(enabled && key !== void 0);
1681
+ const [recovering, setRecovering] = useState(enabled);
1426
1682
  const handlers = useRef(opts);
1427
1683
  handlers.current = opts;
1428
1684
  useEffect(() => {
1429
- if (!enabled || key === void 0) return;
1685
+ if (!enabled) return;
1430
1686
  let cancelled = false;
1431
1687
  setRecovering(true);
1432
1688
  getClient().find(workflow, key, { limit: 1 }).then((found) => {
@@ -1489,6 +1745,99 @@ function useRunControls(runId, getClient) {
1489
1745
  };
1490
1746
  }
1491
1747
  //#endregion
1748
+ //#region src/_upload-pause.ts
1749
+ /**
1750
+ * The two buttons a page puts over a live upload, bound to whichever gate the
1751
+ * submission is currently holding.
1752
+ *
1753
+ * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
1754
+ * both reported the pause the same way: park the gate, then fold `paused` into
1755
+ * the status the bar is already drawing. Folded rather than replaced, because
1756
+ * everything else about that status — which file, how far, of how many — is
1757
+ * still true. Two copies of that rule are two copies that can stop agreeing
1758
+ * about what a paused bar says.
1759
+ */
1760
+ /**
1761
+ * Bind pause/resume to the live submission's gate.
1762
+ *
1763
+ * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
1764
+ * stable across submissions rather than being re-created per gate.
1765
+ * @param setUpload - The status setter the progress bar renders from.
1766
+ *
1767
+ * @internal
1768
+ */
1769
+ function useUploadPause(getGate, setUpload) {
1770
+ const setPaused = useCallback((paused) => {
1771
+ setUpload((current) => current ? {
1772
+ ...current,
1773
+ paused
1774
+ } : current);
1775
+ }, [setUpload]);
1776
+ return {
1777
+ pauseUpload: useCallback(() => {
1778
+ getGate()?.pause();
1779
+ setPaused(true);
1780
+ }, [getGate, setPaused]),
1781
+ resumeUpload: useCallback(() => {
1782
+ getGate()?.resume();
1783
+ setPaused(false);
1784
+ }, [getGate, setPaused])
1785
+ };
1786
+ }
1787
+ //#endregion
1788
+ //#region src/_submission-state.ts
1789
+ /** The shared submission scaffold. See the module doc. */
1790
+ function useSubmissionState() {
1791
+ const [runId, setRunId] = useState(void 0);
1792
+ const [starting, setStarting] = useState(false);
1793
+ const [startError, setStartError] = useState(void 0);
1794
+ const [upload, setUpload] = useState(void 0);
1795
+ const current = useRef(void 0);
1796
+ const begin = useCallback((token) => {
1797
+ setStarting(true);
1798
+ setStartError(void 0);
1799
+ setRunId(void 0);
1800
+ current.current?.gate.cancel();
1801
+ current.current = token;
1802
+ }, []);
1803
+ const end = useCallback((token) => {
1804
+ if (current.current !== token) return;
1805
+ current.current = void 0;
1806
+ setStarting(false);
1807
+ setUpload(void 0);
1808
+ }, []);
1809
+ const reset = useCallback(() => {
1810
+ current.current?.gate.cancel();
1811
+ current.current = void 0;
1812
+ setRunId(void 0);
1813
+ setStartError(void 0);
1814
+ setUpload(void 0);
1815
+ }, []);
1816
+ const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => current.current?.gate, []), setUpload);
1817
+ return {
1818
+ runId,
1819
+ starting,
1820
+ startError,
1821
+ upload,
1822
+ actions: useMemo(() => ({
1823
+ setRunId,
1824
+ setStartError,
1825
+ setUpload,
1826
+ begin,
1827
+ end,
1828
+ reset,
1829
+ pauseUpload,
1830
+ resumeUpload
1831
+ }), [
1832
+ begin,
1833
+ end,
1834
+ reset,
1835
+ pauseUpload,
1836
+ resumeUpload
1837
+ ])
1838
+ };
1839
+ }
1840
+ //#endregion
1492
1841
  //#region src/_upload-recall.ts
1493
1842
  /**
1494
1843
  * Where an upload's ID survives a page RELOAD.
@@ -1554,9 +1903,9 @@ function fingerprint(file) {
1554
1903
  }
1555
1904
  /** This scope's remembered ids, or nothing at all — a parse failure is nothing. */
1556
1905
  function read(scope) {
1906
+ const raw = storageGet("session", keyFor(scope));
1907
+ if (raw === void 0) return {};
1557
1908
  try {
1558
- const raw = globalThis.sessionStorage?.getItem(keyFor(scope));
1559
- if (raw === null || raw === void 0) return {};
1560
1909
  const parsed = JSON.parse(raw);
1561
1910
  return isRecord(parsed) ? parsed : {};
1562
1911
  } catch {
@@ -1564,9 +1913,7 @@ function read(scope) {
1564
1913
  }
1565
1914
  }
1566
1915
  function write(scope, entries) {
1567
- try {
1568
- globalThis.sessionStorage?.setItem(keyFor(scope), JSON.stringify(entries));
1569
- } catch {}
1916
+ storageSet("session", keyFor(scope), JSON.stringify(entries));
1570
1917
  }
1571
1918
  /**
1572
1919
  * The id this file was last being stored under in this tab, if any.
@@ -1710,7 +2057,7 @@ function createUploadGate() {
1710
2057
  *
1711
2058
  * Its own module because both submit hooks need the identical answer and then do
1712
2059
  * two different things with it — `useWorkflowSubmit` stores each file and passes
1713
- * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
2060
+ * its id, `useWorkflowStream` mints the id first so the run can start on it.
1714
2061
  * A second copy of this predicate would be a form field that one hook treats as a
1715
2062
  * file and the other does not, which is invisible until the run reads the wrong
1716
2063
  * kind of string.
@@ -1914,43 +2261,39 @@ async function uploadFiles(api, input, report, parallel, session) {
1914
2261
  return out;
1915
2262
  }
1916
2263
  //#endregion
1917
- //#region src/_upload-pause.ts
2264
+ //#region src/_upload-report.ts
1918
2265
  /**
1919
- * The two buttons a page puts over a live upload, bound to whichever gate the
1920
- * submission is currently holding.
2266
+ * What the bar draws, as a comparable string.
1921
2267
  *
1922
- * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
1923
- * both reported the pause the same way: park the gate, then fold `paused` into
1924
- * the status the bar is already drawing. Folded rather than replaced, because
1925
- * everything else about that status — which file, how far, of how many — is
1926
- * still true. Two copies of that rule are two copies that can stop agreeing
1927
- * about what a paused bar says.
2268
+ * The fraction is quantized to a tenth of a percent — finer than anything the
2269
+ * bar or the byte readout resolves. Everything else is compared exactly:
2270
+ * `paused` is a state change a person is waiting to see, and the name/index
2271
+ * pair moving means a different FILE, which must never be coalesced away.
1928
2272
  */
2273
+ function renderKey(status) {
2274
+ const { name, index, count, loaded, total, fraction, paused } = status;
2275
+ const shown = fraction === void 0 ? loaded : Math.round(fraction * 1e3);
2276
+ return `${paused} ${index} ${count} ${total ?? -1} ${shown} ${name}`;
2277
+ }
1929
2278
  /**
1930
- * Bind pause/resume to the live submission's gate.
2279
+ * Wrap an `UploadStatus` setter so redundant reports never reach React.
1931
2280
  *
1932
- * @param getGate - Reads the gate out of the caller's ref, so the callbacks stay
1933
- * stable across submissions rather than being re-created per gate.
1934
- * @param setUpload - The status setter the progress bar renders from.
1935
- *
1936
- * @internal
2281
+ * Clearing the status (`undefined`) always passes and resets the comparison —
2282
+ * it ends one file's bar, and the next file must not be coalesced against the
2283
+ * previous one's last frame.
1937
2284
  */
1938
- function useUploadPause(getGate, setUpload) {
1939
- const setPaused = useCallback((paused) => {
1940
- setUpload((current) => current ? {
1941
- ...current,
1942
- paused
1943
- } : current);
1944
- }, [setUpload]);
1945
- return {
1946
- pauseUpload: useCallback(() => {
1947
- getGate()?.pause();
1948
- setPaused(true);
1949
- }, [getGate, setPaused]),
1950
- resumeUpload: useCallback(() => {
1951
- getGate()?.resume();
1952
- setPaused(false);
1953
- }, [getGate, setPaused])
2285
+ function coalesceUploadReports(set) {
2286
+ let last;
2287
+ return (status) => {
2288
+ if (status === void 0) {
2289
+ last = void 0;
2290
+ set(void 0);
2291
+ return;
2292
+ }
2293
+ const key = renderKey(status);
2294
+ if (key === last) return;
2295
+ last = key;
2296
+ set(status);
1954
2297
  };
1955
2298
  }
1956
2299
  //#endregion
@@ -2022,81 +2365,69 @@ function useUploadPause(getGate, setUpload) {
2022
2365
  *
2023
2366
  * @public
2024
2367
  */
2025
- function useWorkflowSubmit(workflow, opts = {}) {
2026
- const { api, recover = true, wait, intervalMs, parallel } = opts;
2027
- const key = useDefaultRunKey(opts.key);
2028
- const [runId, setRunId] = useState(void 0);
2029
- const [starting, setStarting] = useState(false);
2030
- const [startError, setStartError] = useState(void 0);
2031
- const [upload, setUpload] = useState(void 0);
2032
- const session = useRef(void 0);
2368
+ function useWorkflowSubmit(workflow, options = {}) {
2369
+ const { api, recover = true, wait, intervalMs, parallel } = options;
2370
+ const key = useDefaultRunKey(options.key);
2371
+ const state = useSubmissionState();
2372
+ const { runId, actions } = state;
2033
2373
  const getClient = useWorkflowApiRef(api);
2034
2374
  const tracked = useWorkflowRun(runId, omitUndefined({
2035
2375
  api,
2036
2376
  intervalMs
2037
2377
  }));
2038
2378
  const { wake, cancel } = useRunControls(runId, getClient);
2379
+ const [startedHere, setStartedHere] = useState(false);
2039
2380
  const recovering = useRecoveredRun({
2040
2381
  workflow,
2041
2382
  key,
2042
2383
  enabled: recover,
2043
2384
  getClient,
2044
2385
  onFound: (found) => {
2045
- setRunId((current) => current ?? found);
2386
+ actions.setRunId((current) => current ?? found);
2046
2387
  },
2047
- onError: setStartError
2388
+ onError: actions.setStartError
2048
2389
  });
2049
2390
  const submit = useCallback(async (input) => {
2050
2391
  const client = getClient();
2051
- setStarting(true);
2052
- setStartError(void 0);
2053
- setRunId(void 0);
2054
- session.current?.gate.cancel();
2055
2392
  const current = createUploadSession(workflow);
2056
- session.current = current;
2393
+ setStartedHere(true);
2394
+ actions.begin(current);
2057
2395
  try {
2058
2396
  const options = omitUndefined({ key });
2059
- const started = await uploadFiles(client, input, setUpload, parallel, current);
2060
- setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
2397
+ const started = await uploadFiles(client, input, coalesceUploadReports(actions.setUpload), parallel, current);
2398
+ actions.setRunId(wait === void 0 ? await client.start(workflow, started, options) : (await client.startAndWait(workflow, started, {
2061
2399
  ...options,
2062
2400
  wait
2063
2401
  })).runId);
2064
2402
  } catch (err) {
2065
- if (!current.gate.cancelled) setStartError(errorMessage(err));
2403
+ if (!current.gate.cancelled) actions.setStartError(errorMessage(err));
2066
2404
  } finally {
2067
- if (session.current === current) {
2068
- session.current = void 0;
2069
- setStarting(false);
2070
- setUpload(void 0);
2071
- }
2405
+ actions.end(current);
2072
2406
  }
2073
2407
  }, [
2074
2408
  workflow,
2075
2409
  key,
2076
2410
  wait,
2077
2411
  parallel,
2078
- getClient
2412
+ getClient,
2413
+ actions
2079
2414
  ]);
2080
- const reset = useCallback(() => {
2081
- session.current?.gate.cancel();
2082
- session.current = void 0;
2083
- setRunId(void 0);
2084
- setStartError(void 0);
2085
- setUpload(void 0);
2086
- }, []);
2087
- const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => session.current?.gate, []), setUpload);
2088
2415
  return {
2089
2416
  submit,
2090
2417
  submitForm: submit,
2091
- reset,
2418
+ reset: useCallback(() => {
2419
+ setStartedHere(false);
2420
+ actions.reset();
2421
+ }, [actions]),
2092
2422
  wake,
2093
2423
  cancel,
2094
- pauseUpload,
2095
- resumeUpload,
2424
+ pauseUpload: actions.pauseUpload,
2425
+ resumeUpload: actions.resumeUpload,
2096
2426
  run: tracked.run,
2097
- pending: recovering || starting || tracked.polling,
2098
- upload,
2099
- error: startError ?? tracked.error
2427
+ startedHere,
2428
+ pending: recovering || state.starting || tracked.polling,
2429
+ upload: state.upload,
2430
+ error: state.startError ?? tracked.error
2100
2431
  };
2101
2432
  }
2102
2433
  //#endregion
@@ -2141,8 +2472,8 @@ function useWorkflowSubmit(workflow, opts = {}) {
2141
2472
  *
2142
2473
  * @public
2143
2474
  */
2144
- function useWorkflowRuns(workflow, opts = {}) {
2145
- const { api, limit, key, skip = false } = opts;
2475
+ function useWorkflowRuns(workflow, options = {}) {
2476
+ const { api, limit, key, skip = false } = options;
2146
2477
  const [runs, setRuns] = useState([]);
2147
2478
  const [loading, setLoading] = useState(!skip && workflow !== void 0);
2148
2479
  const [error, setError] = useState(void 0);
@@ -2226,7 +2557,7 @@ function useWorkflowRuns(workflow, opts = {}) {
2226
2557
  * earlier version of this hook took a `cut` callback and uploaded N parts into a
2227
2558
  * "group" that a separate call had to seal; it worked, and every piece of it was
2228
2559
  * something the caller had to get right. The whole of that is replaced by the store
2229
- * publishing `size` as bytes land — which `readUpload` already clamped to — so the
2560
+ * publishing `size` as bytes land — which `stepReadUpload` already clamped to — so the
2230
2561
  * run does exactly what it does over a finished file and simply waits for windows to
2231
2562
  * become present.
2232
2563
  *
@@ -2302,27 +2633,23 @@ function useWorkflowRuns(workflow, opts = {}) {
2302
2633
  *
2303
2634
  * @public
2304
2635
  */
2305
- function useWorkflowStream(workflow, opts = {}) {
2306
- const { api, key, intervalMs, parallel } = opts;
2307
- const [runId, setRunId] = useState(void 0);
2308
- const [starting, setStarting] = useState(false);
2309
- const [startError, setStartError] = useState(void 0);
2310
- const [upload, setUpload] = useState(void 0);
2311
- const gateRef = useRef(void 0);
2636
+ function useWorkflowStream(workflow, options = {}) {
2637
+ const { api, key, intervalMs, parallel } = options;
2638
+ const state = useSubmissionState();
2639
+ const { runId, actions } = state;
2312
2640
  const getClient = useWorkflowApiRef(api);
2313
2641
  const tracked = useWorkflowRun(runId, omitUndefined({
2314
2642
  api,
2315
2643
  intervalMs
2316
2644
  }));
2317
2645
  const { wake, cancel } = useRunControls(runId, getClient);
2646
+ const [startedHere, setStartedHere] = useState(false);
2318
2647
  const submit = useCallback(async (input) => {
2319
2648
  const client = getClient();
2320
- setStarting(true);
2321
- setStartError(void 0);
2322
- setRunId(void 0);
2323
- gateRef.current?.cancel();
2324
- const gate = createUploadGate();
2325
- gateRef.current = gate;
2649
+ const current = { gate: createUploadGate() };
2650
+ const { gate } = current;
2651
+ setStartedHere(true);
2652
+ actions.begin(current);
2326
2653
  let started;
2327
2654
  try {
2328
2655
  const id = randomUploadId();
@@ -2335,7 +2662,7 @@ function useWorkflowStream(workflow, opts = {}) {
2335
2662
  });
2336
2663
  started = begun.runId;
2337
2664
  const chosen = begun.file;
2338
- setRunId(started);
2665
+ actions.setRunId(started);
2339
2666
  if (!chosen) return;
2340
2667
  await streamFile({
2341
2668
  client,
@@ -2343,45 +2670,38 @@ function useWorkflowStream(workflow, opts = {}) {
2343
2670
  id,
2344
2671
  file: chosen,
2345
2672
  parallel,
2346
- report: setUpload
2673
+ report: coalesceUploadReports(actions.setUpload)
2347
2674
  });
2348
2675
  await client.wake(started).catch(() => void 0);
2349
2676
  } catch (err) {
2350
- if (!gate.cancelled) setStartError(errorMessage(err));
2677
+ if (!gate.cancelled) actions.setStartError(errorMessage(err));
2351
2678
  if (started) await client.cancel(started).catch(() => void 0);
2352
2679
  } finally {
2353
- if (gateRef.current === gate) {
2354
- gateRef.current = void 0;
2355
- setStarting(false);
2356
- setUpload(void 0);
2357
- }
2680
+ actions.end(current);
2358
2681
  }
2359
2682
  }, [
2360
2683
  workflow,
2361
2684
  key,
2362
2685
  parallel,
2363
- getClient
2686
+ getClient,
2687
+ actions
2364
2688
  ]);
2365
- const reset = useCallback(() => {
2366
- gateRef.current?.cancel();
2367
- gateRef.current = void 0;
2368
- setRunId(void 0);
2369
- setStartError(void 0);
2370
- setUpload(void 0);
2371
- }, []);
2372
- const { pauseUpload, resumeUpload } = useUploadPause(useCallback(() => gateRef.current, []), setUpload);
2373
2689
  return {
2374
2690
  submit,
2375
2691
  submitForm: submit,
2376
- reset,
2692
+ reset: useCallback(() => {
2693
+ setStartedHere(false);
2694
+ actions.reset();
2695
+ }, [actions]),
2377
2696
  wake,
2378
2697
  cancel,
2379
- pauseUpload,
2380
- resumeUpload,
2698
+ pauseUpload: actions.pauseUpload,
2699
+ resumeUpload: actions.resumeUpload,
2381
2700
  run: tracked.run,
2382
- pending: starting || tracked.polling,
2383
- upload,
2384
- error: startError ?? tracked.error
2701
+ startedHere,
2702
+ pending: state.starting || tracked.polling,
2703
+ upload: state.upload,
2704
+ error: state.startError ?? tracked.error
2385
2705
  };
2386
2706
  }
2387
2707
  /**
@@ -2392,8 +2712,8 @@ function useWorkflowStream(workflow, opts = {}) {
2392
2712
  * listing in state would make a submit before it landed a race, and the failure
2393
2713
  * mode of that race is a `File` reaching a run input.
2394
2714
  */
2395
- async function beginRun(opts) {
2396
- const { client, workflow, input, id } = opts;
2715
+ async function beginRun(options) {
2716
+ const { client, workflow, input, id } = options;
2397
2717
  const field = await uploadField(client, workflow);
2398
2718
  const chosen = field === void 0 ? void 0 : fileAt(input, field);
2399
2719
  const payload = chosen && field ? {
@@ -2402,7 +2722,7 @@ async function beginRun(opts) {
2402
2722
  } : input;
2403
2723
  assertSendable(workflow, payload, field);
2404
2724
  return {
2405
- runId: await client.start(workflow, payload, omitUndefined({ key: opts.key })),
2725
+ runId: await client.start(workflow, payload, omitUndefined({ key: options.key })),
2406
2726
  file: chosen
2407
2727
  };
2408
2728
  }
@@ -2414,8 +2734,8 @@ async function beginRun(opts) {
2414
2734
  * the id, start the run, then the bytes, then the wake — and the sending is the
2415
2735
  * one step of that list with a loop in it.
2416
2736
  */
2417
- async function streamFile(opts) {
2418
- const { client, gate, id, file, parallel, report } = opts;
2737
+ async function streamFile(options) {
2738
+ const { client, gate, id, file, parallel, report } = options;
2419
2739
  await sendThroughGate(gate, async (resume) => {
2420
2740
  await client.uploadStream(id, file, {
2421
2741
  name: file.name,
@@ -2503,4 +2823,4 @@ const WORKFLOW_STATUS_LABELS = {
2503
2823
  cancelled: "Cancelled"
2504
2824
  };
2505
2825
  //#endregion
2506
- 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 };
2826
+ 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, fieldKindFor, isTerminal, mountClient, mountPage, useAgentState, useConversation, useCopy, useDownloadUrl, useEvent, useFlash, useRunKey, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };