@alexkroman1/aai-ui 0.17.0 → 0.18.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 (71) hide show
  1. package/README.md +6 -7
  2. package/dist/_submission-timing.d.ts +40 -0
  3. package/dist/_submission.d.ts +56 -0
  4. package/dist/_theme.d.ts +17 -0
  5. package/dist/_theme.test-d.d.ts +14 -0
  6. package/dist/{_utils-Mi-SCFA1.js → _utils-BlZBJPdj.js} +4 -5
  7. package/dist/_utils.d.ts +4 -5
  8. package/dist/agent-state.d.ts +4 -2
  9. package/dist/append-dictation.d.ts +23 -0
  10. package/dist/audio/dictation.d.ts +36 -0
  11. package/dist/audio/index.d.ts +4 -2
  12. package/dist/audio/index.js +44 -1
  13. package/dist/{chat-view-W6KcdjJY.js → chat-view-BwKpF0MN.js} +27 -17
  14. package/dist/{client-config-CzvWmw4X.js → client-config-DcMCWmkO.js} +1 -1
  15. package/dist/components/button.js +1 -1
  16. package/dist/components/chat-view.js +1 -2
  17. package/dist/components/controls.js +2 -2
  18. package/dist/components/form-fields.d.ts +24 -5
  19. package/dist/components/form-types.d.ts +32 -0
  20. package/dist/components/form.d.ts +1 -1
  21. package/dist/components/message-list.js +1 -2
  22. package/dist/components/session-state-dot.d.ts +30 -18
  23. package/dist/components/sidebar-layout.js +1 -1
  24. package/dist/components/start-screen.js +2 -2
  25. package/dist/components/tool-call-block.js +1 -2
  26. package/dist/components/tool-config-context.d.ts +21 -4
  27. package/dist/components/workflow-fields.d.ts +36 -12
  28. package/dist/components/workflow-pending-note.d.ts +30 -12
  29. package/dist/context-BF0KsI9X.js +473 -0
  30. package/dist/context.js +1 -381
  31. package/dist/default-client/assets/audio-CtBvSS3L.js +1 -0
  32. package/dist/default-client/assets/{capture-processor-Cx1AdMHL.js → capture-processor-BZXisGjZ.js} +1 -1
  33. package/dist/default-client/assets/index-BkjCv4MX.css +2 -0
  34. package/dist/default-client/assets/index-DUQoaxdq.js +127 -0
  35. package/dist/default-client/assets/{playback-processor-CCwTknSu.js → playback-processor-BHk-iXEU.js} +1 -1
  36. package/dist/default-client/index.html +2 -2
  37. package/dist/default-client.d.ts +1 -3
  38. package/dist/define-client.d.ts +30 -28
  39. package/dist/define-client.js +84 -43
  40. package/dist/dictation-transcriber.d.ts +49 -0
  41. package/dist/{eyebrow-UfmSz9yy.js → eyebrow-Bp4y1rZ9.js} +1 -1
  42. package/dist/{hooks-DN4gHYsj.js → hooks-1CoQY_Fc.js} +2 -2
  43. package/dist/hooks.js +1 -2
  44. package/dist/index.d.ts +11 -8
  45. package/dist/index.js +849 -263
  46. package/dist/internal.d.ts +1 -1
  47. package/dist/internal.js +6 -6
  48. package/dist/{message-list-BsaBkBgo.js → message-list-BBHLK05X.js} +3 -3
  49. package/dist/route-fetch.d.ts +7 -1
  50. package/dist/session/index.js +1 -1
  51. package/dist/{session-3lgPYNNO.js → session-Rud8mmU-.js} +2 -2
  52. package/dist/{tool-call-block-uXtMOUWN.js → tool-call-block-C8s6AhjS.js} +4 -4
  53. package/dist/tool-config-context-Bd-7xrfP.js +28 -0
  54. package/dist/types.d.ts +47 -2
  55. package/dist/{url-chips-oyy_Eiqh.js → url-chips-C_SHU6fq.js} +2 -2
  56. package/dist/use-dictation.d.ts +145 -0
  57. package/dist/use-route.d.ts +49 -7
  58. package/dist/use-run-key.d.ts +6 -1
  59. package/dist/{use-user-transcript-uyHhzy4d.js → use-user-transcript-lOu_uMnf.js} +1 -1
  60. package/dist/use-workflow-form.d.ts +74 -172
  61. package/dist/{use-workflow-run-DdhWmbx-.js → use-workflow-run-BTEemod0.js} +2 -2
  62. package/dist/use-workflow-runs.d.ts +8 -2
  63. package/dist/use-workflow-stream.d.ts +9 -5
  64. package/dist/workflow-def-types.d.ts +18 -2
  65. package/dist/workflow-field-kind.d.ts +59 -7
  66. package/dist/workflow-submission.d.ts +265 -0
  67. package/package.json +2 -2
  68. package/dist/default-client/assets/audio-Ct7JpSNT.js +0 -1
  69. package/dist/default-client/assets/index-BlUYCTB5.js +0 -126
  70. package/dist/default-client/assets/index-_-x5JLLa.css +0 -2
  71. package/dist/tool-config-context-Es4YUzV2.js +0 -19
package/dist/index.js CHANGED
@@ -1,28 +1,58 @@
1
- import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-W6KcdjJY.js";
2
- import { n as pageBaseUrl, r as setPageTitle, t as isTypingTarget } from "./_utils-Mi-SCFA1.js";
3
- import { n as fetchClientConfig, t as buildAgentUrl } from "./client-config-CzvWmw4X.js";
4
- import { a as ticketCarriage, c as resolveReported, d as storageSet, f as urlSlot, i as resolveSessionTokenSync, l as storageGet, r as resolveSessionToken, s as browserClientId, t as createBrowserSession, u as storageRemove } from "./session-3lgPYNNO.js";
5
- import { ThemeProvider, useOptionalSessionCore, useSession, useSessionActions, useSessionCore, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
6
- import { a as useAgentState, i as selectAgentState, n as useToolCallStart, r as useToolResult, t as useEvent } from "./hooks-DN4gHYsj.js";
7
- import { a as useConversation, i as interleave, n as Markdown, o as AutoScroll, r as ConversationView, t as MessageList } from "./message-list-BsaBkBgo.js";
1
+ import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-BwKpF0MN.js";
2
+ import { n as pageBaseUrl, r as setPageTitle, t as isTypingTarget } from "./_utils-BlZBJPdj.js";
3
+ import { n as fetchClientConfig, t as buildAgentUrl } from "./client-config-DcMCWmkO.js";
4
+ import { a as ticketCarriage, c as resolveReported, d as storageSet, f as urlSlot, i as resolveSessionTokenSync, l as storageGet, r as resolveSessionToken, s as browserClientId, t as createBrowserSession, u as storageRemove } from "./session-Rud8mmU-.js";
5
+ import { a as useSessionActions, c as useSessionSelector, i as useSession, l as useSessionStatus, n as ThemeProvider, o as useSessionCore, r as useOptionalSessionCore, s as useSessionError, u as useTheme } from "./context-BF0KsI9X.js";
6
+ import { a as useAgentState, i as selectAgentState, n as useToolCallStart, r as useToolResult, t as useEvent } from "./hooks-1CoQY_Fc.js";
7
+ import { a as useConversation, i as interleave, n as Markdown, o as AutoScroll, r as ConversationView, t as MessageList } from "./message-list-BBHLK05X.js";
8
8
  import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
9
9
  import { Button } from "./components/button.js";
10
- import { a as useFlash, i as useCopy } from "./url-chips-oyy_Eiqh.js";
10
+ import { a as useFlash, i as useCopy } from "./url-chips-C_SHU6fq.js";
11
11
  import { Controls } from "./components/controls.js";
12
- import { n as useUserTranscript } from "./use-user-transcript-uyHhzy4d.js";
13
- import { n as ToolCallRow } from "./tool-call-block-uXtMOUWN.js";
12
+ import { n as useUserTranscript } from "./use-user-transcript-lOu_uMnf.js";
13
+ import { n as ToolCallRow } from "./tool-call-block-C8s6AhjS.js";
14
14
  import { SidebarLayout } from "./components/sidebar-layout.js";
15
15
  import { StartScreen } from "./components/start-screen.js";
16
- import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-DdhWmbx-.js";
16
+ import { a as useWorkflowProgress, c as createWorkflowApi, l as isTerminal, o as repeatUntil, r as useWorkflowRun, s as useWorkflowApiRef } from "./use-workflow-run-BTEemod0.js";
17
17
  import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
18
18
  import { errorMessage, errorMessage as errorMessage$1 } from "@alexkroman1/aai";
19
19
  import { InboxServerFrameSchema } from "@alexkroman1/aai/protocol";
20
20
  import { CLIENT_ID_RE, createBackoffLoop, createEpoch } from "@alexkroman1/aai/internal";
21
21
  import { Fragment, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
22
+ import { formatBytes, isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
22
23
  import clsx from "clsx";
23
24
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
24
- import { formatBytes, isRecord, omitUndefined, safeJsonParse as safeJsonParse$1 } from "@alexkroman1/aai/utils";
25
25
  import { assign, createActor, fromPromise, raise, setup, stateIn } from "xstate";
26
+ import { DICTATION_MAX_SECONDS, DICTATION_SAMPLE_RATE } from "@alexkroman1/aai/experimental";
27
+ //#region src/append-dictation.ts
28
+ /**
29
+ * `appendDictation(draft, text)` — where dictated words land in a text box
30
+ * that may already hold some.
31
+ */
32
+ /**
33
+ * `draft` with `text` added after it, joined by exactly one space: a draft
34
+ * ending in whitespace or a newline does not grow a double space, and an empty
35
+ * or blank draft is replaced rather than given a leading one.
36
+ *
37
+ * Never REPLACES what was typed — the words someone said are an addition to
38
+ * the box, which is what makes speaking into a half-written prompt safe.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { appendDictation } from "@alexkroman1/aai-ui";
43
+ *
44
+ * appendDictation("Build me an agent", "that books tables."); // "Build me an agent that books tables."
45
+ * appendDictation("", "Hello"); // "Hello"
46
+ * ```
47
+ *
48
+ * @public
49
+ */
50
+ function appendDictation(draft, text) {
51
+ const words = text.trim();
52
+ if (!words) return draft;
53
+ return draft.trim() ? `${draft.trimEnd()} ${words}` : words;
54
+ }
55
+ //#endregion
26
56
  //#region src/client-tool.ts
27
57
  /**
28
58
  * The page's half of a `clientTool`: run the handler when the agent calls the
@@ -399,10 +429,27 @@ function useDeclareFieldsPending(pending) {
399
429
  async function collectValues(form) {
400
430
  const values = {};
401
431
  for (const element of Array.from(form.elements)) if (element instanceof HTMLInputElement) await readInput(element, values);
402
- else if ((element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name !== "" && !element.disabled) values[element.name] = element instanceof HTMLSelectElement ? readSelect(element) : element.value;
432
+ else if ((element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) && element.name !== "" && !element.disabled) values[element.name] = element instanceof HTMLSelectElement ? readSelect(element) : readTextArea(element);
403
433
  return values;
404
434
  }
405
435
  /**
436
+ * One `<textarea>`: its string, or with `split="lines"` the non-blank lines
437
+ * trimmed — "one point per line", for a schema's `string[]`. Blank lines go, so
438
+ * a trailing newline is not a requirement to cover `""`.
439
+ */
440
+ function readTextArea(textarea) {
441
+ if (splitMode(textarea.dataset.aaiSplit) !== "lines") return textarea.value;
442
+ return textarea.value.split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
443
+ }
444
+ /** The `data-aai-split` attribute as a {@link TextSplitMode}, defaulting to `"none"`. */
445
+ function splitMode(raw) {
446
+ return raw === "lines" ? raw : "none";
447
+ }
448
+ /** The `data-aai-as` attribute as a {@link FileValueShape}, defaulting to `"file"`. */
449
+ function valueShape(raw) {
450
+ return raw === "namedText" ? raw : "file";
451
+ }
452
+ /**
406
453
  * One `<select>`, by arity.
407
454
  *
408
455
  * `HTMLSelectElement.value` is the FIRST selected option and nothing more, so a
@@ -411,10 +458,22 @@ async function collectValues(form) {
411
458
  * for a list. `selectedOptions` is the whole of what the user picked; nothing
412
459
  * selected contributes `[]`, which is the honest answer for a control that is
413
460
  * present and empty rather than one that was left blank.
461
+ *
462
+ * `data-aai-value="number"` (set by `<WorkflowFields>` for a numeric choice
463
+ * set) reads each chosen option back as a NUMBER: an `<option>` can only hold a
464
+ * string, and `"1"` against `z.literal([1, 2])` is a refused submission. An
465
+ * option that is not numeric stays a string, as a number input's does.
414
466
  */
415
467
  function readSelect(select) {
416
- if (!select.multiple) return select.value;
417
- return Array.from(select.selectedOptions, (option) => option.value);
468
+ const read = select.dataset.aaiValue === "number" ? asNumber : (value) => value;
469
+ if (!select.multiple) return read(select.value);
470
+ return Array.from(select.selectedOptions, (option) => read(option.value));
471
+ }
472
+ /** The option's value as a number where it is one — `""` and prose stay strings. */
473
+ function asNumber(value) {
474
+ if (value.trim() === "") return value;
475
+ const parsed = Number(value);
476
+ return Number.isFinite(parsed) ? parsed : value;
418
477
  }
419
478
  /** One `<input>`, by type. */
420
479
  async function readInput(input, values) {
@@ -436,7 +495,7 @@ async function readInput(input, values) {
436
495
  case "file": {
437
496
  const files = Array.from(input.files ?? []);
438
497
  if (files.length === 0) return;
439
- const chosen = await readFiles(files, readMode(input.dataset.aaiRead));
498
+ const chosen = await readFiles(files, readMode(input.dataset.aaiRead), valueShape(input.dataset.aaiAs));
440
499
  values[input.name] = input.multiple ? chosen : chosen[0];
441
500
  return;
442
501
  }
@@ -451,8 +510,12 @@ async function readInput(input, values) {
451
510
  * the bytes is the upload request `useWorkflowSubmit` makes — which streams the
452
511
  * same `File` object straight to the agent.
453
512
  */
454
- function readFiles(files, read) {
513
+ function readFiles(files, read, shape) {
455
514
  if (read === "upload") return Promise.resolve([...files]);
515
+ if (shape === "namedText") return Promise.all(files.map(async (file) => ({
516
+ name: file.name,
517
+ text: await file.text()
518
+ })));
456
519
  return Promise.all(files.map((file) => describeFile(file, read)));
457
520
  }
458
521
  /** The `data-aai-read` attribute as a {@link FileReadMode}, defaulting to `"none"`. */
@@ -657,11 +720,18 @@ function NumberField(props) {
657
720
  * Accepts every `<textarea>` attribute except `name` and `className`, plus the
658
721
  * shared {@link FieldShell} props. `rows` defaults to 4.
659
722
  *
660
- * @param props - {@link FieldShell} props plus `<textarea>` attributes.
723
+ * **`split="lines"` contributes a `string[]`** — one entry per non-blank line,
724
+ * trimmed — which is the control for a schema's `z.array(z.string())`: "one
725
+ * point per line". `document-redline-workflow` reshaped the textarea's string
726
+ * by hand on submit; the field does it now, so `<WorkflowFields>` can render
727
+ * it for a `string[]` property and a hand-written one can match. See
728
+ * {@link TextSplitMode}.
729
+ *
730
+ * @param props - {@link FieldShell} props, `split`, and `<textarea>` attributes.
661
731
  *
662
732
  * @public
663
733
  */
664
- function TextAreaField({ name, label, hint, className, rows = 4, ...rest }) {
734
+ function TextAreaField({ name, label, hint, className, rows = 4, split = "none", ...rest }) {
665
735
  const id = useId();
666
736
  const control = useControlProps();
667
737
  return /* @__PURE__ */ jsx(Field, {
@@ -673,6 +743,7 @@ function TextAreaField({ name, label, hint, className, rows = 4, ...rest }) {
673
743
  id,
674
744
  name,
675
745
  rows,
746
+ "data-aai-split": split,
676
747
  ...control,
677
748
  ...rest
678
749
  })
@@ -750,6 +821,10 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
750
821
  })
751
822
  });
752
823
  }
824
+ /** The read a non-upload field makes: `namedText` is a reading of the text, so it asks for it. */
825
+ function readModeFor(read, as) {
826
+ return as === "namedText" && read === "none" ? "text" : read;
827
+ }
753
828
  /**
754
829
  * A file picker. Contributes a {@link FileValue} (or an array, with `multiple`)
755
830
  * to {@link FormValues} — or nothing when no file was chosen.
@@ -769,18 +844,26 @@ function CheckboxField({ name, label, hint, className, ...rest }) {
769
844
  * {@link FileReadMode} for the four values; `upload` is shorthand for
770
845
  * `read="upload"`.
771
846
  *
847
+ * **`as="namedText"` contributes `{ name, text }`** ({@link NamedTextValue})
848
+ * instead of the metadata-bearing {@link FileValue}, and implies
849
+ * `read="text"`: the shape a schema declares for a draft to redline or a
850
+ * config to apply, and the one `<WorkflowFields>` renders for an object
851
+ * property of exactly that shape. `document-redline-workflow` reshaped the
852
+ * `FileValue` by hand on submit; the field does it now.
853
+ *
772
854
  * Otherwise accepts every `<input>` attribute except `name`, `className` and
773
855
  * `type`, plus the shared {@link FieldShell} props — so `accept` and
774
856
  * `multiple` are passed straight through.
775
857
  *
776
- * @param props - {@link FieldShell} props, `read`/`upload`, and `<input>`
858
+ * @param props - {@link FieldShell} props, `read`/`upload`/`as`, and `<input>`
777
859
  * attributes.
778
860
  *
779
861
  * @public
780
862
  */
781
- function FileField({ name, label, hint, className, read = "none", upload = false, ...rest }) {
863
+ function FileField({ name, label, hint, className, read = "none", upload = false, as = "file", ...rest }) {
782
864
  const id = useId();
783
865
  const control = useFileControlProps();
866
+ const mode = upload ? "upload" : readModeFor(read, as);
784
867
  return /* @__PURE__ */ jsx(Field, {
785
868
  label,
786
869
  hint,
@@ -790,7 +873,8 @@ function FileField({ name, label, hint, className, read = "none", upload = false
790
873
  id,
791
874
  name,
792
875
  type: "file",
793
- "data-aai-read": upload ? "upload" : read,
876
+ "data-aai-read": mode,
877
+ "data-aai-as": as,
794
878
  ...control,
795
879
  ...rest
796
880
  })
@@ -1366,16 +1450,82 @@ function useWorkflows(options = {}) {
1366
1450
  * way it treats `"none"`.
1367
1451
  */
1368
1452
  /**
1453
+ * Whether `schema` is an object of exactly `{ name: string, text: string }`,
1454
+ * both required — the one object shape with a declared control.
1455
+ */
1456
+ function isNamedTextSchema(schema) {
1457
+ if (schemaTypeOf(schema) !== "object" || !isRecord(schema.properties)) return false;
1458
+ const keys = Object.keys(schema.properties);
1459
+ if (keys.length !== 2 || !keys.includes("name") || !keys.includes("text")) return false;
1460
+ const required = Array.isArray(schema.required) ? schema.required : [];
1461
+ return required.includes("name") && required.includes("text") && schemaTypeOf(schema.properties.name) === "string" && schemaTypeOf(schema.properties.text) === "string";
1462
+ }
1463
+ /** Whether `schema` is an array of strings. */
1464
+ function isStringListSchema(schema) {
1465
+ return schemaTypeOf(schema) === "array" && schemaTypeOf(schema.items) === "string";
1466
+ }
1467
+ /** The members of a schema's `anyOf`/`oneOf`, when it is a union at all. */
1468
+ function unionMembersOf(schema) {
1469
+ const members = schema.anyOf ?? schema.oneOf;
1470
+ return Array.isArray(members) ? members : void 0;
1471
+ }
1472
+ /** `{ type: "null" }` — the half of a nullable union that wants no control. */
1473
+ function isNullSchema(member) {
1474
+ return isRecord(member) && member.type === "null";
1475
+ }
1476
+ /**
1477
+ * The schema with a NULLABLE union unwrapped: `anyOf: [X, { type: "null" }]`
1478
+ * reads as `X`, the way `type: ["string", "null"]` reads as `"string"`.
1479
+ *
1480
+ * Zod 4 converts `z.enum([…]).nullable()` (and any nullable whose inner schema
1481
+ * carries keywords of its own) to that `anyOf`, where a bare
1482
+ * `z.string().nullable()` becomes the type list — so a required nullable enum
1483
+ * used to reach the type switch with no `type` at all, render NO control, and
1484
+ * leave a required property out of every submission. A union with two or more
1485
+ * non-null members is some other shape and is answered as itself; a schema
1486
+ * that is not an object has no answer.
1487
+ *
1488
+ * Not exported from the package barrel, like {@link schemaTypeOf}.
1489
+ */
1490
+ function nonNullSchemaOf(schema) {
1491
+ if (!isRecord(schema)) return void 0;
1492
+ const members = unionMembersOf(schema);
1493
+ if (members === void 0) return schema;
1494
+ const rest = members.filter((member) => !isNullSchema(member));
1495
+ return rest.length === 1 ? nonNullSchemaOf(rest[0]) : schema;
1496
+ }
1497
+ /**
1498
+ * The choices a select renders for `schema`, or undefined when it is not a
1499
+ * closed set: a non-empty `enum`, or the `const` of each member of a union of
1500
+ * literals — which is how zod 4 converts `z.union([z.literal("p"),
1501
+ * z.literal("q")])`, where `z.enum` and `z.literal([1, 2])` convert to `enum`.
1502
+ * The values keep their own type (`[1, 2]` stays numeric), so the control can
1503
+ * submit what the schema declared.
1504
+ *
1505
+ * Not exported from the package barrel, like {@link schemaTypeOf}.
1506
+ */
1507
+ function schemaChoicesOf(schema) {
1508
+ const inner = nonNullSchemaOf(schema);
1509
+ if (inner === void 0) return void 0;
1510
+ if (Array.isArray(inner.enum)) return inner.enum.length > 0 ? inner.enum : void 0;
1511
+ const members = unionMembersOf(inner)?.filter((member) => !isNullSchema(member));
1512
+ if (members === void 0 || members.length === 0) return void 0;
1513
+ const consts = members.map((member) => isRecord(member) ? member.const : void 0);
1514
+ return consts.every((value) => value !== void 0) ? consts : void 0;
1515
+ }
1516
+ /**
1369
1517
  * A property's declared type, taking the first non-null member of a union.
1370
1518
  *
1371
1519
  * `["string", "null"]` is how an optional-and-nullable field converts; the
1372
- * control is the one the non-null half wants. Not exported from the package
1373
- * barrel — the published question is the KIND, and this is the one detail
1520
+ * control is the one the non-null half wants. A nullable `anyOf` is read the
1521
+ * same way ({@link nonNullSchemaOf}). Not exported from the package barrel —
1522
+ * the published question is the KIND, and this is the one detail
1374
1523
  * `<WorkflowFields>` needs past it (an integer's `step`).
1375
1524
  */
1376
1525
  function schemaTypeOf(schema) {
1377
- if (!isRecord(schema)) return void 0;
1378
- const { type } = schema;
1526
+ const inner = nonNullSchemaOf(schema);
1527
+ if (inner === void 0) return void 0;
1528
+ const { type } = inner;
1379
1529
  if (typeof type === "string") return type;
1380
1530
  return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
1381
1531
  }
@@ -1390,10 +1540,18 @@ function schemaTypeOf(schema) {
1390
1540
  * person to type an id no person has. The declaration is the workflow's
1391
1541
  * (`workflow({ uploads: [...] })`), not the schema's, precisely because a
1392
1542
  * marker inside the schema would only work for the library that carried it.
1393
- * 2. **Then a non-empty `enum`**, before the type switch: an enum of strings
1394
- * is a `string` too, and a select is the narrower, better control.
1543
+ * 2. **Then a closed set of choices**, before the type switch: a non-empty
1544
+ * `enum`, or a union of `const` literals (`schemaChoicesOf`) — an enum
1545
+ * of strings is a `string` too, and a select is the narrower, better
1546
+ * control. A nullable union (`anyOf: [X, { type: "null" }]`) is read as `X`
1547
+ * throughout, as `type: [X, "null"]` already was.
1395
1548
  * 3. **Then the type** — `boolean`, `number`/`integer`, `string`.
1396
- * 4. **Anything else is `"none"`** rather than a guess. See
1549
+ * 4. **Then the two non-scalar shapes with a control** — an array of strings
1550
+ * is `"lines"`, an object of exactly `{ name: string, text: string }` is
1551
+ * `"namedText"`. A page may also name either through `render`, which beats
1552
+ * the schema for that property (it is the author saying what the shape
1553
+ * means) but never a declared upload.
1554
+ * 5. **Anything else is `"none"`** rather than a guess. See
1397
1555
  * {@link WorkflowFieldKind}.
1398
1556
  *
1399
1557
  * @example
@@ -1416,20 +1574,24 @@ function schemaTypeOf(schema) {
1416
1574
  * @param schema - The property's JSON Schema, as `GET workflows` reports it.
1417
1575
  * Anything that is not an object reads as `"none"`.
1418
1576
  * @param options - `upload` says the property is named in the workflow's own
1419
- * `uploads` declaration, which is what step 1 above tests.
1577
+ * `uploads` declaration, which is what step 1 above tests; `render` is the
1578
+ * page's own choice for the property (`<WorkflowFields render>`).
1420
1579
  *
1421
1580
  * @public
1422
1581
  */
1423
1582
  function fieldKindFor(schema, options) {
1424
1583
  if (options?.upload === true) return "file";
1425
- if (!isRecord(schema)) return "none";
1426
- const { enum: choices } = schema;
1427
- if (Array.isArray(choices) && choices.length > 0) return "select";
1428
- switch (schemaTypeOf(schema)) {
1584
+ if (options?.render !== void 0) return options.render;
1585
+ const inner = nonNullSchemaOf(schema);
1586
+ if (inner === void 0) return "none";
1587
+ if (schemaChoicesOf(inner) !== void 0) return "select";
1588
+ switch (schemaTypeOf(inner)) {
1429
1589
  case "boolean": return "checkbox";
1430
1590
  case "number":
1431
1591
  case "integer": return "number";
1432
1592
  case "string": return "text";
1593
+ case "array": return isStringListSchema(inner) ? "lines" : "none";
1594
+ case "object": return isNamedTextSchema(inner) ? "namedText" : "none";
1433
1595
  default: return "none";
1434
1596
  }
1435
1597
  }
@@ -1445,18 +1607,28 @@ function fieldKindFor(schema, options) {
1445
1607
  * workflow's form matches its schema by construction, so adding a field to the
1446
1608
  * schema adds it to the page and nothing can drift.
1447
1609
  *
1448
- * ## It covers SCALARS, and says so rather than guessing
1610
+ * ## It covers SCALARS and two more shapes, and says so rather than guessing
1449
1611
  *
1450
- * A string, number, integer, boolean or enum has one obvious control each. A
1451
- * nested object or an array does not — every choice (a JSON textarea, a repeater,
1612
+ * A string, number, integer, boolean or enum has one obvious control each. Most
1613
+ * nested objects and arrays do not — every choice (a JSON textarea, a repeater,
1452
1614
  * a comma-separated string) is a guess about what the author meant, and a guess
1453
1615
  * that produces a value the schema then rejects is worse than no field at all.
1454
1616
  * So those are SKIPPED, and the fields for them are written by hand: every field
1455
1617
  * in this package is a plain named control, so a hand-written one composes with
1456
1618
  * a generated one inside the same {@link Form}.
1619
+ *
1620
+ * Two non-scalar shapes DO have an honest control, and are rendered: an array
1621
+ * of strings as `<TextAreaField split="lines">` (one per line), and an object
1622
+ * of exactly `{ name: string, text: string }` as `<FileField read="text"
1623
+ * as="namedText">` (a draft, read in the browser). A page may also ask for
1624
+ * either by property name through `render`, for a schema that is one of those
1625
+ * but does not say so plainly. Both were the hand-written half of
1626
+ * `document-redline-workflow`'s mixed form, reshaped on submit; the fields
1627
+ * contribute the schema's shape now, so the form is all declared.
1457
1628
  */
1458
1629
  /**
1459
- * Render one field per scalar property of a workflow's input schema.
1630
+ * Render one field per property of a workflow's input schema that has an
1631
+ * honest control — every scalar, an array of strings, a `{ name, text }` object.
1460
1632
  *
1461
1633
  * Pass the workflow's NAME and the schema is fetched here; pass a
1462
1634
  * {@link WorkflowSummary} you already hold and nothing is fetched. The name form
@@ -1487,31 +1659,45 @@ function fieldKindFor(schema, options) {
1487
1659
  * }
1488
1660
  * ```
1489
1661
  *
1490
- * @param props - Field-set props.
1662
+ * @param props - See {@link WorkflowFieldsProps}.
1491
1663
  *
1492
1664
  * @public
1493
1665
  */
1494
- function WorkflowFields({ workflow }) {
1495
- const { workflows, loading } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1666
+ function WorkflowFields({ workflow, render }) {
1667
+ const { workflows, loading, error } = useWorkflows(typeof workflow === "string" ? {} : { skip: true });
1496
1668
  const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
1497
1669
  useDeclareFieldsPending(loading);
1670
+ const unknownName = typeof workflow === "string" && !loading && error === void 0 && summary === void 0;
1671
+ const declared = workflows.map((entry) => entry.name).join(", ");
1672
+ useEffect(() => {
1673
+ if (!unknownName) return;
1674
+ console.warn(`[aai-ui] <WorkflowFields workflow=${JSON.stringify(workflow)}> matches no workflow the agent declares (${declared === "" ? "none listed" : declared}); it renders no fields. Check the name against agent({ workflows }).`);
1675
+ }, [
1676
+ unknownName,
1677
+ workflow,
1678
+ declared
1679
+ ]);
1498
1680
  const schema = asObjectSchema(summary?.inputSchema);
1499
1681
  if (!schema?.properties) return null;
1500
1682
  const required = new Set(schema.required ?? []);
1501
1683
  const uploads = new Set(summary?.uploads ?? []);
1502
- return /* @__PURE__ */ jsx(Fragment$1, { children: Object.entries(schema.properties).map(([name, property]) => /* @__PURE__ */ jsx(SchemaField, {
1684
+ return /* @__PURE__ */ jsx(Fragment$1, { children: Object.entries(schema.properties).map(([name, property]) => isRecord(property) ? /* @__PURE__ */ jsx(SchemaField, {
1503
1685
  name,
1504
1686
  property,
1505
1687
  required: required.has(name),
1506
- upload: uploads.has(name)
1507
- }, name)) });
1688
+ upload: uploads.has(name),
1689
+ render: render?.[name]
1690
+ }, name) : null) });
1508
1691
  }
1509
1692
  /** One property's control, or nothing when its type has no obvious one. */
1510
- function SchemaField({ name, property, required, upload = false }) {
1693
+ function SchemaField({ name, property, required, upload = false, render }) {
1511
1694
  const label = humanize(name);
1512
1695
  const hint = property.description === void 0 ? {} : { hint: property.description };
1513
1696
  const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
1514
- switch (fieldKindFor(property, { upload })) {
1697
+ switch (fieldKindFor(property, {
1698
+ upload,
1699
+ render
1700
+ })) {
1515
1701
  case "file": return /* @__PURE__ */ jsx(FileField, {
1516
1702
  name,
1517
1703
  label,
@@ -1519,14 +1705,35 @@ function SchemaField({ name, property, required, upload = false }) {
1519
1705
  upload: true,
1520
1706
  ...hint
1521
1707
  });
1522
- case "select": return /* @__PURE__ */ jsx(SelectField, {
1708
+ case "lines": return /* @__PURE__ */ jsx(TextAreaField, {
1523
1709
  name,
1524
1710
  label,
1525
1711
  required,
1526
- options: (property.enum ?? []).map((value) => String(value)),
1527
- ...hint,
1528
- ...defaults
1712
+ split: "lines",
1713
+ rows: 3,
1714
+ ...hint
1715
+ });
1716
+ case "namedText": return /* @__PURE__ */ jsx(FileField, {
1717
+ name,
1718
+ label,
1719
+ required,
1720
+ read: "text",
1721
+ as: "namedText",
1722
+ ...hint
1529
1723
  });
1724
+ case "select": {
1725
+ const choices = schemaChoicesOf(property) ?? [];
1726
+ const numeric = choices.length > 0 && choices.every((value) => typeof value === "number");
1727
+ return /* @__PURE__ */ jsx(SelectField, {
1728
+ name,
1729
+ label,
1730
+ required,
1731
+ options: choices.map((value) => String(value)),
1732
+ ...omitUndefined({ "data-aai-value": numeric ? "number" : void 0 }),
1733
+ ...hint,
1734
+ ...defaults
1735
+ });
1736
+ }
1530
1737
  case "checkbox": return /* @__PURE__ */ jsx(CheckboxField, {
1531
1738
  name,
1532
1739
  label,
@@ -1569,7 +1776,7 @@ function humanize(name) {
1569
1776
  //#region src/components/workflow-pending-note.tsx
1570
1777
  /** @jsxImportSource react */
1571
1778
  /**
1572
- * The one sentence a page says while a run is in flight — three situations,
1779
+ * The one sentence a page says while a run is in flight — five situations,
1573
1780
  * one line each — as a muted line under the form, and nothing otherwise.
1574
1781
  *
1575
1782
  * Six template pages had each written the function under this, byte-identical
@@ -1592,9 +1799,18 @@ function humanize(name) {
1592
1799
  *
1593
1800
  * `startedHere`, `run` and `pending` are what {@link WorkflowSubmission}
1594
1801
  * reports, which is why the prop is the submission itself rather than three
1595
- * booleans a page would re-derive. A page with a FOURTH situation — a run that a
1596
- * reload does not recover but ENDS, as `transcription-workflow`'s streaming
1597
- * flow has — writes its own sentence, and that template is the one doing so.
1802
+ * booleans a page would re-derive. The two situations past those three read
1803
+ * two more of its fields, and used to be the one page that wrote its own note:
1804
+ *
1805
+ * - **A run nothing will find again** (`scope: "none"` — `useWorkflowStream`,
1806
+ * whose run reads the recording as this page sends it and gives up on one
1807
+ * that stops arriving, or `recover: false`): "keep this tab open", because
1808
+ * a reload does not orphan that run, it ENDS it. A page telling a reader they
1809
+ * can close the tab would be wrong in exactly that mode.
1810
+ * - **A PAUSED upload** is that same fact arriving through a button, which is
1811
+ * why it is tested first and why its two sentences differ: with no run yet
1812
+ * a pause costs nothing, and in the streaming mode the run is already
1813
+ * watching an upload it will give up on.
1598
1814
  *
1599
1815
  * @example
1600
1816
  * ```tsx
@@ -1610,15 +1826,18 @@ function humanize(name) {
1610
1826
  *
1611
1827
  * @public
1612
1828
  */
1613
- function WorkflowPendingNote({ submission, subject = "run", scope = "tab", className }) {
1829
+ function WorkflowPendingNote({ submission, subject = "run", scope, className }) {
1614
1830
  if (!submission.pending) return null;
1615
1831
  return /* @__PURE__ */ jsx("p", {
1616
1832
  className: clsx("text-sm opacity-70", className),
1617
- children: pendingNote(submission, subject, scope)
1833
+ children: pendingNote(submission, subject, scope ?? submission.scope ?? "tab")
1618
1834
  });
1619
1835
  }
1620
- /** The sentence itself, over the three states the doc above names. */
1836
+ /** The sentence itself, over the five states the doc above names. */
1621
1837
  function pendingNote(submission, subject, scope) {
1838
+ const recoverable = scope !== "none";
1839
+ if (submission.upload?.paused === true) return recoverable ? "Upload paused — nothing has started yet, so resume whenever you are ready." : `Upload paused — resume within a few minutes: the ${subject} has already started and gives up on a file that stops arriving.`;
1840
+ if (!recoverable) return `Keep this tab open — the ${subject} is being made from what this page sends, so a reload ends it.`;
1622
1841
  const here = scope === "browser" ? "this browser" : "this tab";
1623
1842
  if (submission.startedHere) return scope === "browser" ? `You can close this tab — this browser will find the ${subject} again.` : `Reloading is safe — this page will find the ${subject} again.`;
1624
1843
  if (submission.run === void 0) return `Looking for the ${subject} ${here} started earlier…`;
@@ -2059,6 +2278,58 @@ function parseLog(raw, max) {
2059
2278
  if (!Array.isArray(parsed)) return [];
2060
2279
  return trim(parsed.map(parseEntry).filter((e) => e !== void 0), max);
2061
2280
  }
2281
+ //#endregion
2282
+ //#region src/dictation-transcriber.ts
2283
+ /**
2284
+ * `dictationTranscriber(url)` — the `transcribe` callback `useDictation` needs,
2285
+ * for a server answering the SDK's `handleDictationRequest`.
2286
+ *
2287
+ * Every page that dictates writes the same few lines: POST the clip, read
2288
+ * `{ text }`, and turn a refusal into an error whose message is the SERVER's
2289
+ * sentence ("Recording too long — keep it under 120 seconds", "Transcription
2290
+ * is rate limited — try again shortly") rather than a bare status. This is
2291
+ * those lines, against the one reply shape the SDK route answers with.
2292
+ */
2293
+ /**
2294
+ * A `transcribe` for `useDictation` that POSTs each clip to `url` and
2295
+ * reads the `{ text } | { error }` reply `handleDictationRequest` answers with.
2296
+ *
2297
+ * Rejects with the server's own `error` sentence on a refusal, and with a
2298
+ * status-naming message when the reply is not that shape (a proxy's HTML error
2299
+ * page) — `useDictation` shows either as its `error`.
2300
+ *
2301
+ * @param url - The route, resolved like any `fetch` URL (relative to the page).
2302
+ *
2303
+ * @example
2304
+ * ```ts
2305
+ * import { dictationTranscriber } from "@alexkroman1/aai-ui";
2306
+ *
2307
+ * export const transcribe = dictationTranscriber("/api/dictation", {
2308
+ * headers: () => ({ authorization: `Bearer ${localStorage.getItem("token") ?? ""}` }),
2309
+ * });
2310
+ * ```
2311
+ *
2312
+ * @public
2313
+ */
2314
+ function dictationTranscriber(url, options = {}) {
2315
+ return async (audio, { signal }) => {
2316
+ const send = options.fetch ?? fetch;
2317
+ const extra = typeof options.headers === "function" ? options.headers() : options.headers;
2318
+ const res = await send(url, {
2319
+ method: "POST",
2320
+ body: audio,
2321
+ headers: {
2322
+ "Content-Type": audio.type || "audio/wav",
2323
+ ...extra
2324
+ },
2325
+ signal
2326
+ });
2327
+ const body = await res.json().catch(() => void 0);
2328
+ if (res.ok && isRecord(body) && typeof body.text === "string") return body.text;
2329
+ if (isRecord(body) && typeof body.error === "string") throw new Error(body.error);
2330
+ throw new Error(`Dictation failed (HTTP ${res.status})`);
2331
+ };
2332
+ }
2062
2333
  /** Parse a text frame as one of the wire's server frames, or undefined. */
2063
2334
  function parseServerFrame(json) {
2064
2335
  const parsed = InboxServerFrameSchema.safeParse(safeJsonParse$1(json));
@@ -2853,6 +3124,131 @@ function useSubmissionState(recover) {
2853
3124
  };
2854
3125
  }
2855
3126
  //#endregion
3127
+ //#region src/_submission-timing.ts
3128
+ /**
3129
+ * The press-to-settle stopwatch both submit hooks report as
3130
+ * `WorkflowSubmission.timing`: the mark `submit()` was called at, and the mark
3131
+ * the submission settled at, read off the one flag that spans both waits.
3132
+ *
3133
+ * It is the half of `transcription-workflow`'s `useTotalLatency` that is a
3134
+ * fact about the SUBMISSION rather than about the page: that template kept
3135
+ * `startedAt`/`frozenMs` in its own state, armed by its own submit handler
3136
+ * and cleared by its own Clear button — shadow state for two instants only
3137
+ * the hook can place exactly. The rendering half (the interval that re-draws
3138
+ * a running clock, the "before the run / inside it" split) stays on the page,
3139
+ * because it is the page that decides how to print a duration.
3140
+ *
3141
+ * Two details it would be easy to get wrong, both kept from the template:
3142
+ *
3143
+ * - **The settle is measured in the effect that sees `pending` fall**, so the
3144
+ * mark is the moment the run settled rather than whenever the page next
3145
+ * drew. And only after `pending` has been seen TRUE since the press — a
3146
+ * press that lands one render before the submission reports itself in
3147
+ * flight would otherwise freeze the clock at zero.
3148
+ * - **`performance.now()`, not `Date.now()`.** Monotonic; see
3149
+ * `WorkflowSubmissionTiming`.
3150
+ */
3151
+ const NONE = {};
3152
+ /**
3153
+ * The stopwatch over `pending` — see the module doc.
3154
+ *
3155
+ * @param pending - The submission's own `pending`: true from the press until
3156
+ * the run settles, which is the whole of what the clock covers.
3157
+ */
3158
+ function useSubmissionTiming(pending) {
3159
+ const [timing, setTiming] = useState(NONE);
3160
+ const began = useRef(false);
3161
+ useEffect(() => {
3162
+ if (timing.submittedAt === void 0 || timing.settledAt !== void 0) return;
3163
+ if (pending) {
3164
+ began.current = true;
3165
+ return;
3166
+ }
3167
+ if (began.current) {
3168
+ const settledAt = performance.now();
3169
+ setTiming((at) => at.settledAt === void 0 ? {
3170
+ ...at,
3171
+ settledAt
3172
+ } : at);
3173
+ }
3174
+ }, [pending, timing]);
3175
+ return {
3176
+ timing,
3177
+ markSubmitted: useCallback(() => {
3178
+ began.current = false;
3179
+ setTiming({ submittedAt: performance.now() });
3180
+ }, []),
3181
+ clear: useCallback(() => {
3182
+ began.current = false;
3183
+ setTiming(NONE);
3184
+ }, [])
3185
+ };
3186
+ }
3187
+ //#endregion
3188
+ //#region src/_submission.ts
3189
+ /**
3190
+ * Everything a submit hook is, bar its BODY.
3191
+ *
3192
+ * `useWorkflowSubmit` (`use-workflow-form.ts`) and `useWorkflowStream`
3193
+ * (`use-workflow-stream.ts`) differ in one thing — when the run is created
3194
+ * relative to the bytes — and that one thing is the submission body each hands
3195
+ * to the form's statechart. Everything after the body was written out twice,
3196
+ * identically: the watch over the run id (`useWorkflowRun`), the two controls
3197
+ * bound to it (`_run-controls.ts`), `pending` as "the machine is busy or the
3198
+ * watch is still polling", the press-to-settle stopwatch
3199
+ * (`_submission-timing.ts`), a `reset` that clears both, and the fourteen-field
3200
+ * `WorkflowSubmission` assembled from all of it. Two copies of a return object
3201
+ * that have to agree field for field are two copies that can stop agreeing —
3202
+ * the stream hook's type is an ALIAS of the form hook's for the same reason —
3203
+ * so the tail is one function, and each hook keeps only what makes it itself.
3204
+ */
3205
+ /**
3206
+ * The shared tail of a submit hook: the statechart, the watch, the controls,
3207
+ * the stopwatch, and the `WorkflowSubmission` built from them. See the module
3208
+ * doc.
3209
+ */
3210
+ function useSubmission(options) {
3211
+ const { getClient, api, intervalMs, recover, scope, begin } = options;
3212
+ const form = useSubmissionState(recover);
3213
+ const { runId, actions } = form;
3214
+ const tracked = useWorkflowRun(runId, omitUndefined({
3215
+ api,
3216
+ intervalMs
3217
+ }));
3218
+ const { wake, cancel } = useRunControls(runId, getClient);
3219
+ const pending = form.busy || tracked.polling;
3220
+ const { timing, markSubmitted, clear: clearTiming } = useSubmissionTiming(pending);
3221
+ const submit = useCallback((input) => {
3222
+ markSubmitted();
3223
+ const { gate, work } = begin(input, getClient());
3224
+ return actions.submit(gate, work);
3225
+ }, [
3226
+ begin,
3227
+ getClient,
3228
+ actions,
3229
+ markSubmitted
3230
+ ]);
3231
+ return {
3232
+ submit,
3233
+ submitForm: submit,
3234
+ reset: useCallback(() => {
3235
+ clearTiming();
3236
+ actions.reset();
3237
+ }, [actions, clearTiming]),
3238
+ wake,
3239
+ cancel,
3240
+ pauseUpload: actions.pauseUpload,
3241
+ resumeUpload: actions.resumeUpload,
3242
+ run: tracked.run,
3243
+ startedHere: form.startedHere,
3244
+ pending,
3245
+ upload: form.upload,
3246
+ error: form.startError ?? tracked.error,
3247
+ timing,
3248
+ scope
3249
+ };
3250
+ }
3251
+ //#endregion
2856
3252
  //#region src/_workflow-files.ts
2857
3253
  /**
2858
3254
  * Which of a submitted form's values are FILES.
@@ -3424,6 +3820,20 @@ function mintRunKey(storage) {
3424
3820
  return minted;
3425
3821
  }
3426
3822
  /**
3823
+ * Refuse a caller's key that is empty or whitespace.
3824
+ *
3825
+ * A key is the lookup CAPABILITY (see the module doc), so one that computes to
3826
+ * `""` is not "no key": it is ONE key, SHARED by every visitor whose key came
3827
+ * out the same way — `user?.id ?? ""` before the login resolves is the natural
3828
+ * way to write it — and they then record and recover each other's runs. The
3829
+ * server refuses the same value with a 400 (`workflow/api/runs.ts`), but a
3830
+ * refusal at the hook names the line that computed it.
3831
+ */
3832
+ function assertUsableRunKey(key) {
3833
+ if (key.trim() !== "") return;
3834
+ throw new Error(`useWorkflowSubmit({ key }) was given ${JSON.stringify(key)}, an empty key. A key is the lookup capability runs are recorded under, so an empty one would be SHARED by every visitor whose key computed to "" (a \`user?.id ?? ""\` before login is the usual cause). Pass undefined until the key is known — the hook mints a per-page key of its own — or a non-blank string.`);
3835
+ }
3836
+ /**
3427
3837
  * The key `useWorkflowSubmit` uses when the page named none.
3428
3838
  *
3429
3839
  * Two things it does that a plain `useRunKey()` at the call site cannot, and
@@ -3443,12 +3853,15 @@ function mintRunKey(storage) {
3443
3853
  *
3444
3854
  * @param explicit - The caller's own key, or undefined for a page with none.
3445
3855
  * @returns The key to record runs under and look them up by.
3856
+ * @throws When the caller's key is empty or whitespace — see
3857
+ * {@link assertUsableRunKey}.
3446
3858
  *
3447
3859
  * @internal
3448
3860
  */
3449
3861
  function useDefaultRunKey(explicit) {
3450
- const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
3451
- return explicit ?? minted;
3862
+ if (typeof explicit === "string") assertUsableRunKey(explicit);
3863
+ const [minted] = useState(() => typeof explicit === "string" ? "" : mintRunKey(explicit?.storage ?? "session"));
3864
+ return typeof explicit === "string" ? explicit : minted;
3452
3865
  }
3453
3866
  /**
3454
3867
  * A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
@@ -3473,7 +3886,8 @@ function useRunKey(options = {}) {
3473
3886
  * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
3474
3887
  * This is what comes before it: `useWorkflowSubmit` starts a run and hands the
3475
3888
  * id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
3476
- * `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
3889
+ * `<WorkflowFields>` renders a form from — is `use-workflows.ts`. What it hands
3890
+ * back is `WorkflowSubmission` (`workflow-submission.ts`).
3477
3891
  *
3478
3892
  * ## `useWorkflowSubmit` — a form's two halves in one hook
3479
3893
  *
@@ -3501,89 +3915,53 @@ function useRunKey(options = {}) {
3501
3915
  * followed from the same id either way.
3502
3916
  */
3503
3917
  /**
3504
- * Start a workflow from a form, and follow the run it creates.
3505
- *
3506
- * @typeParam D - The workflow DEFINITION, which types both halves of the
3507
- * submission: `submit(input)` takes what the workflow's schema parses to, and
3508
- * `run.status === "completed"` narrows to a typed `run.output`.
3509
- *
3510
- * It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
3511
- * already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
3512
- * `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
3513
- * 400 in the browser. Naming the def instead types the input from the same
3514
- * declaration — and `import type` is ERASED, so it costs the bundle nothing.
3515
- * Passing an output type where a def belongs is now a compile error rather
3516
- * than a silent loss of typing, which is the point.
3517
- *
3518
- * @example
3519
- * ```tsx no-check
3520
- * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
3521
- * import type { digest } from "./agent.ts";
3522
- *
3523
- * function DigestForm() {
3524
- * const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
3525
- * return (
3526
- * <Form onSubmit={(values) => submit(values)} error={error}>
3527
- * <TextField name="url" label="Link" type="url" required />
3528
- * <SubmitButton pending={pending}>Digest</SubmitButton>
3529
- * {run?.status === "completed" && <p>{run.output.title}</p>}
3530
- * </Form>
3531
- * );
3532
- * }
3533
- * ```
3534
- *
3535
- * @public
3918
+ * Who can find the run again, from the two options that decide it — see
3919
+ * `WorkflowSubmissionScope`. A page-supplied string key is read as
3920
+ * `"browser"`: it is at least that wide (an account id is wider still), and
3921
+ * "close this tab" is true of it.
3536
3922
  */
3923
+ function scopeOf(key, recover) {
3924
+ if (!recover) return "none";
3925
+ if (typeof key === "string") return "browser";
3926
+ return key?.storage === "local" ? "browser" : "tab";
3927
+ }
3537
3928
  function useWorkflowSubmit(workflow, options = {}) {
3538
3929
  const { api, recover = true, wait, intervalMs, parallel } = options;
3539
3930
  const key = useDefaultRunKey(options.key);
3931
+ const scope = scopeOf(options.key, recover);
3540
3932
  const getClient = useWorkflowApiRef(api);
3541
- const form = useSubmissionState({
3542
- workflow,
3543
- key,
3544
- enabled: recover,
3545
- getClient
3546
- });
3547
- const { runId, actions } = form;
3548
- const tracked = useWorkflowRun(runId, omitUndefined({
3549
- api,
3550
- intervalMs
3551
- }));
3552
- const { wake, cancel } = useRunControls(runId, getClient);
3553
- const submit = useCallback((input) => {
3554
- const client = getClient();
3933
+ const begin = useCallback((input, client) => {
3555
3934
  const session = createUploadSession(workflow);
3556
- return actions.submit(session.gate, async ({ progress, started }) => {
3557
- const options = omitUndefined({ key });
3558
- const report = coalesceUploadReports(progress);
3559
- const stored = await uploadFiles(client, input, report, parallel, session);
3560
- started(wait === void 0 ? await client.start(workflow, stored, options) : (await client.startAndWait(workflow, stored, {
3561
- ...options,
3562
- wait
3563
- })).runId);
3564
- });
3935
+ return {
3936
+ gate: session.gate,
3937
+ work: async ({ progress, started }) => {
3938
+ const options = omitUndefined({ key });
3939
+ const stored = await uploadFiles(client, input, coalesceUploadReports(progress), parallel, session);
3940
+ started(wait === void 0 ? await client.start(workflow, stored, options) : (await client.startAndWait(workflow, stored, {
3941
+ ...options,
3942
+ wait
3943
+ })).runId);
3944
+ }
3945
+ };
3565
3946
  }, [
3566
3947
  workflow,
3567
3948
  key,
3568
3949
  wait,
3569
- parallel,
3570
- getClient,
3571
- actions
3950
+ parallel
3572
3951
  ]);
3573
- return {
3574
- submit,
3575
- submitForm: submit,
3576
- reset: actions.reset,
3577
- wake,
3578
- cancel,
3579
- pauseUpload: actions.pauseUpload,
3580
- resumeUpload: actions.resumeUpload,
3581
- run: tracked.run,
3582
- startedHere: form.startedHere,
3583
- pending: form.busy || tracked.polling,
3584
- upload: form.upload,
3585
- error: form.startError ?? tracked.error
3586
- };
3952
+ return useSubmission({
3953
+ getClient,
3954
+ api,
3955
+ intervalMs,
3956
+ recover: {
3957
+ workflow,
3958
+ key,
3959
+ enabled: recover,
3960
+ getClient
3961
+ },
3962
+ scope,
3963
+ begin
3964
+ });
3587
3965
  }
3588
3966
  //#endregion
3589
3967
  //#region src/_page-shell.tsx
@@ -3877,7 +4255,7 @@ function phoneE164(typed, options = {}) {
3877
4255
  * @param method - The route's method.
3878
4256
  * @param path - The route's path as declared, starting with `/`; `?query` allowed.
3879
4257
  * @param body - Sent as JSON when given.
3880
- * @param options - `client`, `baseUrl` and `signal`; see {@link RouteFetchOptions}.
4258
+ * @param options - `client`, `baseUrl`, `headers` and `signal`; see {@link RouteFetchOptions}.
3881
4259
  * @returns The parsed JSON body.
3882
4260
  *
3883
4261
  * @public
@@ -3886,13 +4264,18 @@ async function routeFetch(method, path, body, options = {}) {
3886
4264
  const base = options.baseUrl ?? pageBaseUrl();
3887
4265
  const url = new URL(`api${path.startsWith("/") ? path : `/${path}`}`, base);
3888
4266
  if (options.client) url.searchParams.set("client", options.client);
4267
+ const given = Object.fromEntries(Object.entries(options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]));
4268
+ const headers = omitUndefined({
4269
+ "content-type": body === void 0 ? void 0 : "application/json",
4270
+ ...given
4271
+ });
3889
4272
  const res = await fetch(url, {
3890
4273
  method,
3891
- ...body === void 0 ? {} : {
3892
- headers: { "content-type": "application/json" },
3893
- body: JSON.stringify(body)
3894
- },
3895
- ...omitUndefined({ signal: options.signal })
4274
+ ...omitUndefined({
4275
+ headers: Object.keys(headers).length > 0 ? headers : void 0,
4276
+ body: body === void 0 ? void 0 : JSON.stringify(body),
4277
+ signal: options.signal
4278
+ })
3896
4279
  });
3897
4280
  const json = await res.json().catch(() => ({}));
3898
4281
  if (!res.ok) {
@@ -4040,12 +4423,20 @@ function useStoredValue(source, initial) {
4040
4423
  * dropped — `useWorkflowRuns`' rule), a read landing after unmount, and a
4041
4424
  * failure rendered as an empty answer. `data` is KEPT through a failed re-read,
4042
4425
  * so a poll that hiccups does not blank the panel; `error` says it is stale.
4426
+ * It is NOT kept across a change of `path`: the answer is remembered with the
4427
+ * path it came from, so a page switching from one record to the next never
4428
+ * shows the previous one under the new id while the first read is in flight.
4429
+ *
4430
+ * The poll is `repeatUntil`'s loop (`_repeat-until.ts`), re-armed from the
4431
+ * SETTLED read rather than on an interval — a slow route would otherwise
4432
+ * stack overlapping reads — and stopped by `until`, by a change of what is
4433
+ * read, and by unmount, with the in-flight request aborted on the same signal.
4043
4434
  *
4044
4435
  * @module
4045
4436
  */
4046
4437
  /**
4047
4438
  * `GET` one of the agent's own routes, on mount, on `reload()` and every
4048
- * `pollMs` — see this module's doc.
4439
+ * `pollMs` until `until` says so — see this module's doc.
4049
4440
  *
4050
4441
  * @example A list that re-reads after each delete
4051
4442
  * ```tsx
@@ -4075,9 +4466,24 @@ function useStoredValue(source, initial) {
4075
4466
  * }
4076
4467
  * ```
4077
4468
  *
4469
+ * @example A call followed until it is over
4470
+ * ```tsx
4471
+ * import { useRoute } from "@alexkroman1/aai-ui";
4472
+ *
4473
+ * type Call = { status: string; transcript: { role: string; text: string }[] };
4474
+ *
4475
+ * function LiveCall({ id }: { id: string }) {
4476
+ * const { data } = useRoute<Call>(`/calls/${id}`, {
4477
+ * pollMs: 2000,
4478
+ * until: (call) => call.status === "completed",
4479
+ * });
4480
+ * return <ol>{data?.transcript.map((line, i) => <li key={String(i)}>{line.text}</li>)}</ol>;
4481
+ * }
4482
+ * ```
4483
+ *
4078
4484
  * @typeParam T - The shape the route answers. Not checked.
4079
4485
  * @param path - The route's path as declared, without `/api`. `null` reads nothing.
4080
- * @param options - `pollMs` and `client`; see {@link UseRouteOptions}.
4486
+ * @param options - `pollMs`, `until`, `client` and `headers`; see {@link UseRouteOptions}.
4081
4487
  * @returns The answer, the last error, and `reload`; see {@link UseRouteResult}.
4082
4488
  *
4083
4489
  * @public
@@ -4085,49 +4491,85 @@ function useStoredValue(source, initial) {
4085
4491
  function useRoute(path, options = {}) {
4086
4492
  const session = useOptionalSessionCore();
4087
4493
  const { pollMs, client } = options;
4088
- const [data, setData] = useState(void 0);
4089
- const [error, setError] = useState(void 0);
4494
+ if (pollMs !== void 0 && !(Number.isFinite(pollMs) && pollMs >= 0)) throw new Error(`useRoute: pollMs must be a non-negative number, not ${String(pollMs)}`);
4495
+ const [answer, setAnswer] = useState(void 0);
4090
4496
  const [loading, setLoading] = useState(path !== null);
4497
+ const perRequest = useRef({
4498
+ until: options.until,
4499
+ headers: options.headers
4500
+ });
4501
+ perRequest.current = {
4502
+ until: options.until,
4503
+ headers: options.headers
4504
+ };
4091
4505
  const epochRef = useRef(void 0);
4092
4506
  epochRef.current ??= createEpoch();
4093
4507
  const epoch = epochRef.current;
4094
- const reload = useCallback(() => {
4095
- if (path === null) return;
4508
+ /**
4509
+ * One read. Resolves whether the poll (if any) is FINISHED: `until` was
4510
+ * satisfied. A read that was superseded by a newer one (a `reload()`, or the
4511
+ * unmount's bump) answers `false` — its ANSWER is nobody's, but the poll is
4512
+ * still owed its next tick: `true` here would stop `repeatUntil` for good on
4513
+ * the first reload during a poll. The unmount is what stops the loop, through
4514
+ * its own `stop()`, and a loop whose signal is aborted never re-arms.
4515
+ */
4516
+ const read = useCallback(async (signal) => {
4517
+ if (path === null) return true;
4096
4518
  epoch.bump();
4097
4519
  const mine = epoch.current();
4098
4520
  setLoading(true);
4099
- const who = client ?? session?.identity.clientId();
4100
- routeFetch("GET", path, void 0, who ? { client: who } : {}).then((answer) => {
4101
- if (!epoch.isCurrent(mine)) return;
4102
- setData(answer);
4103
- setError(void 0);
4521
+ const { until, headers } = perRequest.current;
4522
+ try {
4523
+ const answer = await routeFetch("GET", path, void 0, { ...omitUndefined({
4524
+ client: client ?? session?.identity.clientId(),
4525
+ headers,
4526
+ signal
4527
+ }) });
4528
+ if (!epoch.isCurrent(mine)) return false;
4529
+ setAnswer({
4530
+ path,
4531
+ data: answer,
4532
+ error: void 0
4533
+ });
4104
4534
  setLoading(false);
4105
- }).catch((err) => {
4106
- if (!epoch.isCurrent(mine)) return;
4107
- setError(errorMessage$1(err));
4535
+ return until?.(answer) === true;
4536
+ } catch (err) {
4537
+ if (!epoch.isCurrent(mine)) return false;
4538
+ setAnswer((prior) => ({
4539
+ path,
4540
+ data: prior?.path === path ? prior.data : void 0,
4541
+ error: errorMessage$1(err)
4542
+ }));
4108
4543
  setLoading(false);
4109
- });
4544
+ return false;
4545
+ }
4110
4546
  }, [
4111
4547
  path,
4112
4548
  client,
4113
4549
  session,
4114
4550
  epoch
4115
4551
  ]);
4552
+ const reload = useCallback(() => {
4553
+ read();
4554
+ }, [read]);
4116
4555
  useEffect(() => {
4117
- reload();
4118
- const timer = pollMs ? setInterval(reload, pollMs) : void 0;
4556
+ const stop = pollMs !== void 0 && pollMs > 0 ? repeatUntil(pollMs, read) : ((controller) => {
4557
+ read(controller.signal);
4558
+ return () => controller.abort();
4559
+ })(new AbortController());
4119
4560
  return () => {
4120
- clearInterval(timer);
4561
+ stop();
4121
4562
  epoch.bump();
4122
4563
  };
4123
4564
  }, [
4124
- reload,
4565
+ read,
4125
4566
  pollMs,
4126
4567
  epoch
4127
4568
  ]);
4569
+ const current = answer?.path === path ? answer : void 0;
4128
4570
  return {
4129
- data,
4130
- error,
4571
+ data: current?.data,
4572
+ error: current?.error,
4131
4573
  loading,
4132
4574
  reload
4133
4575
  };
@@ -4507,6 +4949,209 @@ function append(list, event) {
4507
4949
  return item ? [...list, item] : void 0;
4508
4950
  }
4509
4951
  //#endregion
4952
+ //#region src/use-dictation.ts
4953
+ /**
4954
+ * `useDictation` — speak into a text box: record a clip, have it transcribed,
4955
+ * hand the words to the page.
4956
+ *
4957
+ * Not a session. There is no agent on the other end and no socket: the clip
4958
+ * goes to AssemblyAI's dictation API in ONE request, through a `transcribe`
4959
+ * callback the page supplies — on the far side of which is a server holding
4960
+ * the key, calling `transcribeDictation` from `@alexkroman1/aai/experimental`.
4961
+ * The key never reaches the browser, which is why this hook takes a callback
4962
+ * rather than a credential.
4963
+ *
4964
+ * The two ends that are not this hook are published too: `dictationTranscriber`
4965
+ * is the `transcribe` for a server answering `handleDictationRequest`'s
4966
+ * `{ text } | { error }`, and `appendDictation` adds the words to a draft.
4967
+ *
4968
+ * What it owns, because each is a way a hand-written mic button goes wrong:
4969
+ *
4970
+ * - **The recording indicator goes out.** Every way out — stop, cancel, the
4971
+ * length cap, an unmount mid-recording — releases the microphone.
4972
+ * - **A late answer lands nowhere.** A transcription that settles after a
4973
+ * cancel, a newer recording or an unmount is dropped, never typed into the
4974
+ * box the user has moved on from.
4975
+ * - **The button cannot be pressed into a bad state.** `buttonProps` keeps a
4976
+ * recording stoppable while `disabled`, and takes no press mid-transition.
4977
+ * - **The clip fits the endpoint.** Recording stops by itself at `maxSeconds`
4978
+ * and is transcribed, rather than growing into a clip the service refuses
4979
+ * whole.
4980
+ */
4981
+ const openRecorder = async (opts) => {
4982
+ const [audio, captureWorkletSrc] = await Promise.all([import("./audio/index.js"), import("./worklets/capture-processor.js").then((m) => m.default)]);
4983
+ return audio.openDictationRecorder({
4984
+ ...opts,
4985
+ captureWorkletSrc
4986
+ });
4987
+ };
4988
+ /** Stop `rec` and transcribe the clip; `""` when aborted before the request. */
4989
+ async function transcribeRecording(rec, signal, transcribe) {
4990
+ const wav = await rec.stop();
4991
+ if (signal.aborted) return "";
4992
+ return (await transcribe(new Blob([wav], { type: "audio/wav" }), { signal })).trim();
4993
+ }
4994
+ function browserCanRecord() {
4995
+ return typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function" && typeof AudioContext !== "undefined" && typeof AudioWorkletNode !== "undefined";
4996
+ }
4997
+ /**
4998
+ * Record what the user says and hand it back as text — a microphone button
4999
+ * for a text box.
5000
+ *
5001
+ * @example
5002
+ * ```tsx
5003
+ * import { appendDictation, dictationTranscriber, useDictation } from "@alexkroman1/aai-ui";
5004
+ * import { useState } from "react";
5005
+ *
5006
+ * // `POST /dictation` on your own server is `handleDictationRequest`
5007
+ * // (`@alexkroman1/aai/experimental`), which holds the API key.
5008
+ * const transcribe = dictationTranscriber("/dictation");
5009
+ *
5010
+ * export function SpokenNote() {
5011
+ * const [draft, setDraft] = useState("");
5012
+ * const dictation = useDictation({
5013
+ * transcribe,
5014
+ * onText: (text) => setDraft((d) => appendDictation(d, text)),
5015
+ * });
5016
+ * return (
5017
+ * <div>
5018
+ * <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
5019
+ * {dictation.supported && (
5020
+ * <button type="button" {...dictation.buttonProps}>
5021
+ * {dictation.phase === "recording" ? "Stop" : "Speak"}
5022
+ * </button>
5023
+ * )}
5024
+ * </div>
5025
+ * );
5026
+ * }
5027
+ * ```
5028
+ *
5029
+ * @public
5030
+ */
5031
+ function useDictation(options) {
5032
+ return useDictationWith(options, openRecorder);
5033
+ }
5034
+ /**
5035
+ * {@link useDictation} over an injected recorder — the seam the specs drive.
5036
+ * Not on the barrel.
5037
+ */
5038
+ function useDictationWith(options, open) {
5039
+ const [phase, setPhase] = useState("idle");
5040
+ const [error, setError] = useState(null);
5041
+ const supported = useMemo(browserCanRecord, []);
5042
+ const latest = useRef(options);
5043
+ latest.current = options;
5044
+ const openRef = useRef(open);
5045
+ openRef.current = open;
5046
+ const epoch = useRef(createEpoch()).current;
5047
+ const phaseRef = useRef("idle");
5048
+ const recorder = useRef(null);
5049
+ const inflight = useRef(null);
5050
+ const cap = useRef(null);
5051
+ const move = useCallback((next) => {
5052
+ phaseRef.current = next;
5053
+ setPhase(next);
5054
+ }, []);
5055
+ const clearCap = useCallback(() => {
5056
+ if (cap.current !== null) clearTimeout(cap.current);
5057
+ cap.current = null;
5058
+ }, []);
5059
+ /** Drop whatever is running, releasing the microphone. Leaves `phase` alone. */
5060
+ const abandon = useCallback(() => {
5061
+ epoch.bump();
5062
+ clearCap();
5063
+ inflight.current?.abort();
5064
+ inflight.current = null;
5065
+ const rec = recorder.current;
5066
+ recorder.current = null;
5067
+ rec?.cancel();
5068
+ }, [epoch, clearCap]);
5069
+ const stop = useCallback(() => {
5070
+ const rec = recorder.current;
5071
+ if (phaseRef.current !== "recording" || !rec) return;
5072
+ recorder.current = null;
5073
+ clearCap();
5074
+ move("transcribing");
5075
+ const gen = epoch.current();
5076
+ const controller = new AbortController();
5077
+ inflight.current = controller;
5078
+ const settle = () => {
5079
+ inflight.current = null;
5080
+ move("idle");
5081
+ };
5082
+ transcribeRecording(rec, controller.signal, latest.current.transcribe).then((text) => {
5083
+ if (!epoch.isCurrent(gen)) return;
5084
+ if (text) latest.current.onText(text);
5085
+ settle();
5086
+ }, (err) => {
5087
+ if (!epoch.isCurrent(gen)) return;
5088
+ setError(errorMessage$1(err));
5089
+ settle();
5090
+ });
5091
+ }, [
5092
+ epoch,
5093
+ clearCap,
5094
+ move
5095
+ ]);
5096
+ const start = useCallback(() => {
5097
+ if (phaseRef.current !== "idle" || latest.current.disabled === true) return;
5098
+ setError(null);
5099
+ move("starting");
5100
+ const gen = epoch.current();
5101
+ const maxSeconds = latest.current.maxSeconds ?? DICTATION_MAX_SECONDS;
5102
+ openRef.current({
5103
+ sampleRate: DICTATION_SAMPLE_RATE,
5104
+ maxSeconds
5105
+ }).then((rec) => {
5106
+ if (!epoch.isCurrent(gen)) {
5107
+ rec.cancel();
5108
+ return;
5109
+ }
5110
+ recorder.current = rec;
5111
+ move("recording");
5112
+ cap.current = setTimeout(stop, maxSeconds * 1e3);
5113
+ }).catch((err) => {
5114
+ if (!epoch.isCurrent(gen)) return;
5115
+ setError(errorMessage$1(err));
5116
+ move("idle");
5117
+ });
5118
+ }, [
5119
+ epoch,
5120
+ move,
5121
+ stop
5122
+ ]);
5123
+ const cancel = useCallback(() => {
5124
+ if (phaseRef.current === "idle") return;
5125
+ abandon();
5126
+ move("idle");
5127
+ }, [abandon, move]);
5128
+ const toggle = useCallback(() => {
5129
+ if (phaseRef.current === "idle") start();
5130
+ else if (phaseRef.current === "recording") stop();
5131
+ }, [start, stop]);
5132
+ useEffect(() => abandon, [abandon]);
5133
+ const recording = phase === "recording";
5134
+ const blocked = phase === "starting" || phase === "transcribing" || options.disabled === true && !recording;
5135
+ return {
5136
+ phase,
5137
+ error,
5138
+ supported,
5139
+ start,
5140
+ stop,
5141
+ cancel,
5142
+ toggle,
5143
+ buttonProps: useMemo(() => ({
5144
+ onClick: toggle,
5145
+ disabled: blocked,
5146
+ "aria-pressed": recording
5147
+ }), [
5148
+ toggle,
5149
+ blocked,
5150
+ recording
5151
+ ])
5152
+ };
5153
+ }
5154
+ //#endregion
4510
5155
  //#region src/use-download-url.ts
4511
5156
  /**
4512
5157
  * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
@@ -5367,24 +6012,6 @@ function useTapToTalk(options = {}) {
5367
6012
  * a page calls when its own run settles, which is exactly when the list is
5368
6013
  * stale.
5369
6014
  */
5370
- /**
5371
- * Read a workflow's recent runs.
5372
- *
5373
- * @typeParam R - The workflow's output type, so a completed run's `output` is
5374
- * typed rather than `unknown`. Derive it with `WorkflowOutputOf`.
5375
- *
5376
- * @example
5377
- * ```tsx
5378
- * import { useWorkflowRuns } from "@alexkroman1/aai-ui";
5379
- *
5380
- * function History() {
5381
- * const { runs } = useWorkflowRuns("transcribe", { limit: 10 });
5382
- * return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;
5383
- * }
5384
- * ```
5385
- *
5386
- * @public
5387
- */
5388
6015
  function useWorkflowRuns(workflow, options = {}) {
5389
6016
  const { api, limit, key, skip = false } = options;
5390
6017
  const [runs, setRuns] = useState([]);
@@ -5521,94 +6148,53 @@ function useWorkflowRuns(workflow, options = {}) {
5521
6148
  * `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
5522
6149
  * pause has become an abandonment.
5523
6150
  */
5524
- /**
5525
- * Start a workflow run and stream a file into it while it works.
5526
- *
5527
- * The workflow declares which input property carries the upload
5528
- * (`workflow({ uploads: ["recording"] })`) — the same declaration
5529
- * `useWorkflowSubmit` reads, because what the property carries is an upload id
5530
- * either way. What differs is only WHEN the id becomes valid.
5531
- *
5532
- * @typeParam R - The workflow's output type, which is what makes
5533
- * `run.status === "completed"` narrow to a typed `run.output`. Derive it with
5534
- * `WorkflowOutputOf<typeof myWorkflow>`.
5535
- *
5536
- * @example
5537
- * ```tsx no-check
5538
- * const { submit, run, upload, pending, error } = useWorkflowStream("transcribe");
5539
- *
5540
- * <Form onSubmit={(values) => submit(values)} error={error}>
5541
- * <WorkflowFields workflow="transcribe" />
5542
- * <UploadProgressBar upload={upload} />
5543
- * <SubmitButton pending={pending}>Transcribe</SubmitButton>
5544
- * </Form>
5545
- * ```
5546
- *
5547
- * @public
5548
- */
5549
6151
  function useWorkflowStream(workflow, options = {}) {
5550
6152
  const { api, key, intervalMs, parallel } = options;
5551
- const getClient = useWorkflowApiRef(api);
5552
- const form = useSubmissionState();
5553
- const { runId, actions } = form;
5554
- const tracked = useWorkflowRun(runId, omitUndefined({
6153
+ return useSubmission({
6154
+ getClient: useWorkflowApiRef(api),
5555
6155
  api,
5556
- intervalMs
5557
- }));
5558
- const { wake, cancel } = useRunControls(runId, getClient);
5559
- const submit = useCallback((input) => {
5560
- const client = getClient();
5561
- const gate = createUploadGate();
5562
- return actions.submit(gate, async ({ progress, started: adopt }) => {
5563
- let started;
5564
- try {
5565
- const id = randomUploadId();
5566
- const begun = await beginRun({
5567
- client,
5568
- workflow,
5569
- input,
5570
- id,
5571
- ...omitUndefined({ key })
5572
- });
5573
- started = begun.runId;
5574
- const chosen = begun.file;
5575
- adopt(started);
5576
- if (!chosen) return;
5577
- await streamFile({
5578
- client,
5579
- gate,
5580
- id,
5581
- file: chosen,
5582
- parallel,
5583
- report: coalesceUploadReports(progress)
5584
- });
5585
- await client.wake(started).catch(() => void 0);
5586
- } catch (err) {
5587
- if (started) await client.cancel(started).catch(() => void 0);
5588
- throw err;
5589
- }
5590
- });
5591
- }, [
5592
- workflow,
5593
- key,
5594
- parallel,
5595
- getClient,
5596
- actions
5597
- ]);
5598
- return {
5599
- submit,
5600
- submitForm: submit,
5601
- reset: actions.reset,
5602
- wake,
5603
- cancel,
5604
- pauseUpload: actions.pauseUpload,
5605
- resumeUpload: actions.resumeUpload,
5606
- run: tracked.run,
5607
- startedHere: form.startedHere,
5608
- pending: form.busy || tracked.polling,
5609
- upload: form.upload,
5610
- error: form.startError ?? tracked.error
5611
- };
6156
+ intervalMs,
6157
+ scope: "none",
6158
+ begin: useCallback((input, client) => {
6159
+ const gate = createUploadGate();
6160
+ return {
6161
+ gate,
6162
+ work: async ({ progress, started: adopt }) => {
6163
+ let started;
6164
+ try {
6165
+ const id = randomUploadId();
6166
+ const begun = await beginRun({
6167
+ client,
6168
+ workflow,
6169
+ input,
6170
+ id,
6171
+ ...omitUndefined({ key })
6172
+ });
6173
+ started = begun.runId;
6174
+ const chosen = begun.file;
6175
+ adopt(started);
6176
+ if (!chosen) return;
6177
+ await streamFile({
6178
+ client,
6179
+ gate,
6180
+ id,
6181
+ file: chosen,
6182
+ parallel,
6183
+ report: coalesceUploadReports(progress)
6184
+ });
6185
+ await client.wake(started).catch(() => void 0);
6186
+ } catch (err) {
6187
+ if (started) await client.cancel(started).catch(() => void 0);
6188
+ throw err;
6189
+ }
6190
+ }
6191
+ };
6192
+ }, [
6193
+ workflow,
6194
+ key,
6195
+ parallel
6196
+ ])
6197
+ });
5612
6198
  }
5613
6199
  /**
5614
6200
  * Read the declaration, substitute the id, and start the run.
@@ -5703,4 +6289,4 @@ function fileAt(input, field) {
5703
6289
  return filesOf(input[field])[0];
5704
6290
  }
5705
6291
  //#endregion
5706
- export { AGENT_STATE_LABELS, AudioResult, AutoScroll, BulletList, Button, ChatView, CheckboxField, ConsoleShell, Controls, ConversationView, Facts, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SessionControls, SessionErrorBanner, SessionStateDot, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowPendingNote, WorkflowProgress, WorkflowRunError, WorkflowRunPanel, browserClientId, createBrowserSession, createInbox, createLinkedClient, createStoredValue, createWorkflowApi, errorMessage, fetchClientConfig, fieldKindFor, inboxEventToItem, isFileValue, isTerminal, mountClient, mountPage, phoneE164, routeFetch, selectAgentState, useAgentState, useClientId, useClientRuns, useClientTool, useConversation, useConversationLog, useCopy, useDownloadUrl, useEvent, useFlash, useInbox, usePushToTalk, useRoute, useRouteMutation, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionId, useSessionSelector, useSessionStatus, useStoredValue, useTapToTalk, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
6292
+ export { AGENT_STATE_LABELS, AudioResult, AutoScroll, BulletList, Button, ChatView, CheckboxField, ConsoleShell, Controls, ConversationView, Facts, Field, FileField, Form, Markdown, MessageList, NumberField, SelectField, SessionControls, SessionErrorBanner, SessionStateDot, SidebarLayout, StartScreen, SubmitButton, TextAreaField, TextField, ToolCallRow, UploadProgressBar, WORKFLOW_STATUS_LABELS, WorkflowFields, WorkflowPendingNote, WorkflowProgress, WorkflowRunError, WorkflowRunPanel, appendDictation, browserClientId, createBrowserSession, createInbox, createLinkedClient, createStoredValue, createWorkflowApi, dictationTranscriber, errorMessage, fetchClientConfig, fieldKindFor, inboxEventToItem, isFileValue, isTerminal, mountClient, mountPage, phoneE164, routeFetch, selectAgentState, useAgentState, useClientId, useClientRuns, useClientTool, useConversation, useConversationLog, useCopy, useDictation, useDownloadUrl, useEvent, useFlash, useInbox, usePushToTalk, useRoute, useRouteMutation, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionId, useSessionSelector, useSessionStatus, useStoredValue, useTapToTalk, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };