@alexkroman1/aai-ui 0.16.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 (123) 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-labels.d.ts +8 -3
  9. package/dist/agent-state.d.ts +4 -2
  10. package/dist/append-dictation.d.ts +23 -0
  11. package/dist/audio/dictation.d.ts +36 -0
  12. package/dist/audio/index.d.ts +4 -2
  13. package/dist/audio/index.js +44 -1
  14. package/dist/{chat-view-DIwPoAEG.js → chat-view-BwKpF0MN.js} +65 -45
  15. package/dist/{client-config-C7fQrioM.js → client-config-DcMCWmkO.js} +8 -13
  16. package/dist/client-config.d.ts +29 -10
  17. package/dist/components/_form-readiness.d.ts +2 -1
  18. package/dist/components/audio-result.d.ts +2 -0
  19. package/dist/components/bullet-list.d.ts +1 -0
  20. package/dist/components/button.d.ts +2 -2
  21. package/dist/components/button.js +1 -1
  22. package/dist/components/chat-view.js +1 -2
  23. package/dist/components/console-shell.d.ts +8 -5
  24. package/dist/components/controls.d.ts +1 -0
  25. package/dist/components/controls.js +2 -2
  26. package/dist/components/conversation-view.d.ts +1 -0
  27. package/dist/components/facts.d.ts +1 -0
  28. package/dist/components/form-fields.d.ts +31 -12
  29. package/dist/components/form-types.d.ts +48 -2
  30. package/dist/components/form.d.ts +10 -5
  31. package/dist/components/markdown.d.ts +1 -0
  32. package/dist/components/message-list.d.ts +1 -0
  33. package/dist/components/message-list.js +1 -2
  34. package/dist/components/session-controls.d.ts +7 -2
  35. package/dist/components/session-error-banner.d.ts +1 -0
  36. package/dist/components/session-state-dot.d.ts +45 -20
  37. package/dist/components/sidebar-layout.d.ts +2 -2
  38. package/dist/components/sidebar-layout.js +1 -1
  39. package/dist/components/start-screen.js +2 -2
  40. package/dist/components/tool-call-block.d.ts +3 -3
  41. package/dist/components/tool-call-block.js +1 -2
  42. package/dist/components/tool-call-row.d.ts +1 -0
  43. package/dist/components/tool-config-context.d.ts +23 -5
  44. package/dist/components/url-chips.d.ts +4 -3
  45. package/dist/components/workflow-fields.d.ts +36 -11
  46. package/dist/components/workflow-pending-note.d.ts +31 -12
  47. package/dist/components/workflow-run-error.d.ts +1 -0
  48. package/dist/components/workflow-run-panel.d.ts +1 -0
  49. package/dist/context-BF0KsI9X.js +473 -0
  50. package/dist/context.d.ts +8 -7
  51. package/dist/context.js +1 -381
  52. package/dist/default-client/assets/audio-CtBvSS3L.js +1 -0
  53. package/dist/default-client/assets/{capture-processor-CXCWxCO8.js → capture-processor-BZXisGjZ.js} +1 -1
  54. package/dist/default-client/assets/index-BkjCv4MX.css +2 -0
  55. package/dist/default-client/assets/index-DUQoaxdq.js +127 -0
  56. package/dist/default-client/assets/{playback-processor-DXHfyQD9.js → playback-processor-BHk-iXEU.js} +1 -1
  57. package/dist/default-client/index.html +2 -2
  58. package/dist/default-client.d.ts +1 -3
  59. package/dist/define-client.d.ts +32 -28
  60. package/dist/define-client.js +85 -44
  61. package/dist/dictation-transcriber.d.ts +49 -0
  62. package/dist/{eyebrow-UfmSz9yy.js → eyebrow-Bp4y1rZ9.js} +1 -1
  63. package/dist/{hooks-DN4gHYsj.js → hooks-1CoQY_Fc.js} +2 -2
  64. package/dist/hooks.js +1 -2
  65. package/dist/inbox-protocol.d.ts +14 -4
  66. package/dist/inbox.d.ts +1 -0
  67. package/dist/index.d.ts +12 -9
  68. package/dist/index.js +905 -277
  69. package/dist/internal.d.ts +1 -1
  70. package/dist/internal.js +6 -6
  71. package/dist/linked-client.d.ts +2 -0
  72. package/dist/{message-list-DHZcLhRA.js → message-list-BBHLK05X.js} +24 -13
  73. package/dist/page.d.ts +2 -0
  74. package/dist/phone.d.ts +1 -0
  75. package/dist/route-fetch.d.ts +8 -1
  76. package/dist/session/audio-setup.d.ts +1 -1
  77. package/dist/session/audio-state.d.ts +6 -6
  78. package/dist/session/index.js +1 -1
  79. package/dist/session/messages.d.ts +1 -9
  80. package/dist/session/preconnect.d.ts +1 -1
  81. package/dist/session/types.d.ts +4 -0
  82. package/dist/{session-CYdjRvbl.js → session-Rud8mmU-.js} +26 -15
  83. package/dist/stored-value.d.ts +2 -0
  84. package/dist/{tool-call-block-uXtMOUWN.js → tool-call-block-C8s6AhjS.js} +4 -4
  85. package/dist/tool-config-context-Bd-7xrfP.js +28 -0
  86. package/dist/types.d.ts +95 -6
  87. package/dist/{url-chips-oyy_Eiqh.js → url-chips-C_SHU6fq.js} +2 -2
  88. package/dist/use-client-runs.d.ts +2 -0
  89. package/dist/use-conversation-log.d.ts +2 -0
  90. package/dist/use-conversation.d.ts +24 -9
  91. package/dist/use-copy.d.ts +1 -0
  92. package/dist/use-dictation.d.ts +145 -0
  93. package/dist/use-download-url.d.ts +6 -1
  94. package/dist/use-flash.d.ts +1 -0
  95. package/dist/use-inbox.d.ts +2 -0
  96. package/dist/use-push-to-talk.d.ts +6 -1
  97. package/dist/use-route-mutation.d.ts +3 -0
  98. package/dist/use-route.d.ts +51 -7
  99. package/dist/use-run-key.d.ts +6 -1
  100. package/dist/use-session-controls.d.ts +1 -0
  101. package/dist/use-tap-to-talk.d.ts +5 -2
  102. package/dist/{use-user-transcript-uyHhzy4d.js → use-user-transcript-lOu_uMnf.js} +1 -1
  103. package/dist/use-user-transcript.d.ts +5 -1
  104. package/dist/use-workflow-form.d.ts +76 -167
  105. package/dist/use-workflow-progress.d.ts +5 -0
  106. package/dist/{use-workflow-run-DdhWmbx-.js → use-workflow-run-BTEemod0.js} +2 -2
  107. package/dist/use-workflow-run.d.ts +5 -0
  108. package/dist/use-workflow-runs.d.ts +18 -4
  109. package/dist/use-workflow-stream.d.ts +33 -7
  110. package/dist/use-workflows.d.ts +10 -2
  111. package/dist/workflow-client.d.ts +5 -0
  112. package/dist/workflow-def-types.d.ts +18 -2
  113. package/dist/workflow-field-kind.d.ts +66 -10
  114. package/dist/workflow-submission.d.ts +265 -0
  115. package/dist/worklets/capture-processor.d.ts +3 -3
  116. package/dist/worklets/capture-processor.js +2 -2
  117. package/dist/worklets/playback-processor.d.ts +3 -3
  118. package/dist/worklets/playback-processor.js +2 -2
  119. package/package.json +2 -2
  120. package/dist/default-client/assets/audio-DzwGxdv4.js +0 -1
  121. package/dist/default-client/assets/index-BOTLwsNO.js +0 -126
  122. package/dist/default-client/assets/index-_-x5JLLa.css +0 -2
  123. 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-DIwPoAEG.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-C7fQrioM.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-CYdjRvbl.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-DHZcLhRA.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,13 +873,35 @@ 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
  })
797
881
  });
798
882
  }
799
883
  //#endregion
884
+ //#region src/components/form-types.ts
885
+ /**
886
+ * What a form's controls CONTRIBUTE — the types both halves of the form code
887
+ * share, and {@link isFileValue}, the guard that reads one back.
888
+ *
889
+ * Their own module because `form.tsx` (the components) and `_form-values.ts`
890
+ * (the DOM read) both need them, and a type living in one of those would make
891
+ * the pair import each other.
892
+ */
893
+ /**
894
+ * Whether one {@link FormValues} entry is what a {@link FileField} contributed.
895
+ *
896
+ * A field with nothing chosen contributes no key at all, so the absent case is
897
+ * the ordinary one — this is `false` for `undefined`, not an error.
898
+ *
899
+ * @public
900
+ */
901
+ function isFileValue(value) {
902
+ return isRecord(value) && typeof value.name === "string" && typeof value.size === "number" && typeof value.type === "string" && typeof value.lastModified === "number" && (value.content === void 0 || typeof value.content === "string");
903
+ }
904
+ //#endregion
800
905
  //#region src/components/form.tsx
801
906
  /** @jsxImportSource react */
802
907
  /**
@@ -1019,7 +1124,7 @@ const DEFAULT_VARIANT = {
1019
1124
  };
1020
1125
  function defaultButton({ action, label, onClick }) {
1021
1126
  return /* @__PURE__ */ jsx(Button, {
1022
- variant: DEFAULT_VARIANT[action],
1127
+ variant: DEFAULT_VARIANT[action] ?? "secondary",
1023
1128
  onClick,
1024
1129
  children: label
1025
1130
  });
@@ -1339,21 +1444,88 @@ function useWorkflows(options = {}) {
1339
1444
  * studio's SDK-subpath list: mirroring "would just move the drift somewhere
1340
1445
  * new".
1341
1446
  *
1342
- * So a NEW control added to `<WorkflowFields>` is a new member of
1343
- * {@link WorkflowFieldKind}, and every reader of the rule finds out from the
1344
- * type rather than from a table somebody remembered to edit.
1447
+ * So a NEW control added to `<WorkflowFields>` is a new value of
1448
+ * {@link WorkflowFieldKind}. The set is open, so that addition is not a break:
1449
+ * a reader's `switch` keeps a `default` and treats a kind it does not know the
1450
+ * way it treats `"none"`.
1345
1451
  */
1346
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
+ /**
1347
1517
  * A property's declared type, taking the first non-null member of a union.
1348
1518
  *
1349
1519
  * `["string", "null"]` is how an optional-and-nullable field converts; the
1350
- * control is the one the non-null half wants. Not exported from the package
1351
- * 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
1352
1523
  * `<WorkflowFields>` needs past it (an integer's `step`).
1353
1524
  */
1354
1525
  function schemaTypeOf(schema) {
1355
- if (!isRecord(schema)) return void 0;
1356
- const { type } = schema;
1526
+ const inner = nonNullSchemaOf(schema);
1527
+ if (inner === void 0) return void 0;
1528
+ const { type } = inner;
1357
1529
  if (typeof type === "string") return type;
1358
1530
  return Array.isArray(type) ? type.find((member) => member !== "null") : void 0;
1359
1531
  }
@@ -1368,10 +1540,18 @@ function schemaTypeOf(schema) {
1368
1540
  * person to type an id no person has. The declaration is the workflow's
1369
1541
  * (`workflow({ uploads: [...] })`), not the schema's, precisely because a
1370
1542
  * marker inside the schema would only work for the library that carried it.
1371
- * 2. **Then a non-empty `enum`**, before the type switch: an enum of strings
1372
- * 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.
1373
1548
  * 3. **Then the type** — `boolean`, `number`/`integer`, `string`.
1374
- * 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
1375
1555
  * {@link WorkflowFieldKind}.
1376
1556
  *
1377
1557
  * @example
@@ -1394,20 +1574,24 @@ function schemaTypeOf(schema) {
1394
1574
  * @param schema - The property's JSON Schema, as `GET workflows` reports it.
1395
1575
  * Anything that is not an object reads as `"none"`.
1396
1576
  * @param options - `upload` says the property is named in the workflow's own
1397
- * `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>`).
1398
1579
  *
1399
1580
  * @public
1400
1581
  */
1401
1582
  function fieldKindFor(schema, options) {
1402
1583
  if (options?.upload === true) return "file";
1403
- if (!isRecord(schema)) return "none";
1404
- const { enum: choices } = schema;
1405
- if (Array.isArray(choices) && choices.length > 0) return "select";
1406
- 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)) {
1407
1589
  case "boolean": return "checkbox";
1408
1590
  case "number":
1409
1591
  case "integer": return "number";
1410
1592
  case "string": return "text";
1593
+ case "array": return isStringListSchema(inner) ? "lines" : "none";
1594
+ case "object": return isNamedTextSchema(inner) ? "namedText" : "none";
1411
1595
  default: return "none";
1412
1596
  }
1413
1597
  }
@@ -1423,18 +1607,28 @@ function fieldKindFor(schema, options) {
1423
1607
  * workflow's form matches its schema by construction, so adding a field to the
1424
1608
  * schema adds it to the page and nothing can drift.
1425
1609
  *
1426
- * ## It covers SCALARS, and says so rather than guessing
1610
+ * ## It covers SCALARS and two more shapes, and says so rather than guessing
1427
1611
  *
1428
- * A string, number, integer, boolean or enum has one obvious control each. A
1429
- * 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,
1430
1614
  * a comma-separated string) is a guess about what the author meant, and a guess
1431
1615
  * that produces a value the schema then rejects is worse than no field at all.
1432
1616
  * So those are SKIPPED, and the fields for them are written by hand: every field
1433
1617
  * in this package is a plain named control, so a hand-written one composes with
1434
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.
1435
1628
  */
1436
1629
  /**
1437
- * 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.
1438
1632
  *
1439
1633
  * Pass the workflow's NAME and the schema is fetched here; pass a
1440
1634
  * {@link WorkflowSummary} you already hold and nothing is fetched. The name form
@@ -1465,31 +1659,45 @@ function fieldKindFor(schema, options) {
1465
1659
  * }
1466
1660
  * ```
1467
1661
  *
1468
- * @param props - Field-set props.
1662
+ * @param props - See {@link WorkflowFieldsProps}.
1469
1663
  *
1470
1664
  * @public
1471
1665
  */
1472
- function WorkflowFields({ workflow }) {
1473
- 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 });
1474
1668
  const summary = typeof workflow === "string" ? workflows.find((entry) => entry.name === workflow) : workflow;
1475
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
+ ]);
1476
1680
  const schema = asObjectSchema(summary?.inputSchema);
1477
1681
  if (!schema?.properties) return null;
1478
1682
  const required = new Set(schema.required ?? []);
1479
1683
  const uploads = new Set(summary?.uploads ?? []);
1480
- 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, {
1481
1685
  name,
1482
1686
  property,
1483
1687
  required: required.has(name),
1484
- upload: uploads.has(name)
1485
- }, name)) });
1688
+ upload: uploads.has(name),
1689
+ render: render?.[name]
1690
+ }, name) : null) });
1486
1691
  }
1487
1692
  /** One property's control, or nothing when its type has no obvious one. */
1488
- function SchemaField({ name, property, required, upload = false }) {
1693
+ function SchemaField({ name, property, required, upload = false, render }) {
1489
1694
  const label = humanize(name);
1490
1695
  const hint = property.description === void 0 ? {} : { hint: property.description };
1491
1696
  const defaults = property.default === void 0 ? {} : { defaultValue: String(property.default) };
1492
- switch (fieldKindFor(property, { upload })) {
1697
+ switch (fieldKindFor(property, {
1698
+ upload,
1699
+ render
1700
+ })) {
1493
1701
  case "file": return /* @__PURE__ */ jsx(FileField, {
1494
1702
  name,
1495
1703
  label,
@@ -1497,14 +1705,35 @@ function SchemaField({ name, property, required, upload = false }) {
1497
1705
  upload: true,
1498
1706
  ...hint
1499
1707
  });
1500
- case "select": return /* @__PURE__ */ jsx(SelectField, {
1708
+ case "lines": return /* @__PURE__ */ jsx(TextAreaField, {
1501
1709
  name,
1502
1710
  label,
1503
1711
  required,
1504
- options: (property.enum ?? []).map((value) => String(value)),
1505
- ...hint,
1506
- ...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
1507
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
+ }
1508
1737
  case "checkbox": return /* @__PURE__ */ jsx(CheckboxField, {
1509
1738
  name,
1510
1739
  label,
@@ -1547,7 +1776,7 @@ function humanize(name) {
1547
1776
  //#region src/components/workflow-pending-note.tsx
1548
1777
  /** @jsxImportSource react */
1549
1778
  /**
1550
- * 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,
1551
1780
  * one line each — as a muted line under the form, and nothing otherwise.
1552
1781
  *
1553
1782
  * Six template pages had each written the function under this, byte-identical
@@ -1570,9 +1799,18 @@ function humanize(name) {
1570
1799
  *
1571
1800
  * `startedHere`, `run` and `pending` are what {@link WorkflowSubmission}
1572
1801
  * reports, which is why the prop is the submission itself rather than three
1573
- * booleans a page would re-derive. A page with a FOURTH situation — a run that a
1574
- * reload does not recover but ENDS, as `transcription-workflow`'s streaming
1575
- * 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.
1576
1814
  *
1577
1815
  * @example
1578
1816
  * ```tsx
@@ -1588,15 +1826,18 @@ function humanize(name) {
1588
1826
  *
1589
1827
  * @public
1590
1828
  */
1591
- function WorkflowPendingNote({ submission, subject = "run", scope = "tab", className }) {
1829
+ function WorkflowPendingNote({ submission, subject = "run", scope, className }) {
1592
1830
  if (!submission.pending) return null;
1593
1831
  return /* @__PURE__ */ jsx("p", {
1594
1832
  className: clsx("text-sm opacity-70", className),
1595
- children: pendingNote(submission, subject, scope)
1833
+ children: pendingNote(submission, subject, scope ?? submission.scope ?? "tab")
1596
1834
  });
1597
1835
  }
1598
- /** The sentence itself, over the three states the doc above names. */
1836
+ /** The sentence itself, over the five states the doc above names. */
1599
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.`;
1600
1841
  const here = scope === "browser" ? "this browser" : "this tab";
1601
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.`;
1602
1843
  if (submission.run === void 0) return `Looking for the ${subject} ${here} started earlier…`;
@@ -1835,7 +2076,7 @@ function WorkflowRunPanel({ run, statusLabels, onClear, api, live, children, cla
1835
2076
  */
1836
2077
  /** The item as the log keeps it: a tool call's `result` dropped. */
1837
2078
  function logItem(item) {
1838
- if (item.kind === "message" || item.toolCall.result === void 0) return item;
2079
+ if (item.kind !== "tool" || item.toolCall.result === void 0) return item;
1839
2080
  const { result: _result, ...toolCall } = item.toolCall;
1840
2081
  return {
1841
2082
  kind: "tool",
@@ -1845,7 +2086,11 @@ function logItem(item) {
1845
2086
  /** One item's identity as a turn — ignoring ids and a tool call going pending → done. */
1846
2087
  function turnKey(item) {
1847
2088
  if (!item) return "";
1848
- return item.kind === "message" ? `m${item.message.role}:${item.message.content}` : `t${item.toolCall.name}:${JSON.stringify(item.toolCall.args)}`;
2089
+ switch (item.kind) {
2090
+ case "message": return `m${item.message.role}:${item.message.content}`;
2091
+ case "tool": return `t${item.toolCall.name}:${JSON.stringify(item.toolCall.args)}`;
2092
+ default: return `o${item.type}`;
2093
+ }
1849
2094
  }
1850
2095
  /** The same turn in the same state — what makes a rewrite a no-op. */
1851
2096
  function sameItem(a, b) {
@@ -1976,11 +2221,15 @@ function parseItem(raw) {
1976
2221
  if (!isRecord(raw)) return void 0;
1977
2222
  if (raw.kind === "message" && isRecord(raw.message)) return parseMessage(raw.message);
1978
2223
  if (raw.kind === "tool" && isRecord(raw.toolCall)) return parseToolCall(raw.toolCall);
2224
+ if (typeof raw.kind === "string" && raw.kind !== "message" && raw.kind !== "tool") return {
2225
+ kind: "other",
2226
+ type: raw.kind === "other" && typeof raw.type === "string" ? raw.type : raw.kind
2227
+ };
1979
2228
  }
1980
2229
  function parseMessage(raw) {
1981
2230
  const { id, role, content } = raw;
1982
2231
  if (typeof id !== "number" || typeof content !== "string") return void 0;
1983
- return role === "user" || role === "assistant" ? message(id, role, content) : void 0;
2232
+ return typeof role === "string" && role !== "" ? message(id, role, content) : void 0;
1984
2233
  }
1985
2234
  function parseToolCall(raw) {
1986
2235
  const { callId, name, args, status, seq, afterMessageId } = raw;
@@ -2029,6 +2278,58 @@ function parseLog(raw, max) {
2029
2278
  if (!Array.isArray(parsed)) return [];
2030
2279
  return trim(parsed.map(parseEntry).filter((e) => e !== void 0), max);
2031
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
+ }
2032
2333
  /** Parse a text frame as one of the wire's server frames, or undefined. */
2033
2334
  function parseServerFrame(json) {
2034
2335
  const parsed = InboxServerFrameSchema.safeParse(safeJsonParse$1(json));
@@ -2055,15 +2356,27 @@ function parseNoticeHeader(json) {
2055
2356
  bytes
2056
2357
  };
2057
2358
  }
2359
+ /** The wire frame types this version knows; anything else is an `"other"` event. */
2360
+ const KNOWN_FRAME_TYPES = /* @__PURE__ */ new Set([
2361
+ "notice",
2362
+ "session_event",
2363
+ "session_ended"
2364
+ ]);
2058
2365
  /**
2059
- * A live-event frame, or undefined for anything else (a notice header, say).
2060
- * The wire's live frames are returned as the published {@link InboxEvent}, so
2061
- * that view cannot drift from what the server sends without this failing to
2062
- * compile.
2366
+ * A live-event frame, or undefined for a notice header or a frame that is not
2367
+ * one at all. The wire's live frames are returned as the published
2368
+ * {@link InboxEvent}, so that view cannot drift from what the server sends
2369
+ * without this failing to compile; a well-formed frame of a type this version
2370
+ * does not know is `{ type: "other" }`.
2063
2371
  */
2064
2372
  function parseInboxEvent(json) {
2065
- const frame = parseServerFrame(json);
2066
- return frame === void 0 || frame.type === "notice" ? void 0 : frame;
2373
+ const raw = safeJsonParse$1(json);
2374
+ if (isRecord(raw) && typeof raw.type === "string" && !KNOWN_FRAME_TYPES.has(raw.type)) return {
2375
+ type: "other",
2376
+ frameType: raw.type
2377
+ };
2378
+ const parsed = InboxServerFrameSchema.safeParse(raw);
2379
+ return !parsed.success || parsed.data.type === "notice" ? void 0 : parsed.data;
2067
2380
  }
2068
2381
  /**
2069
2382
  * The receiving state machine of one socket — see the module doc for its four
@@ -2811,6 +3124,131 @@ function useSubmissionState(recover) {
2811
3124
  };
2812
3125
  }
2813
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
2814
3252
  //#region src/_workflow-files.ts
2815
3253
  /**
2816
3254
  * Which of a submitted form's values are FILES.
@@ -3382,6 +3820,20 @@ function mintRunKey(storage) {
3382
3820
  return minted;
3383
3821
  }
3384
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
+ /**
3385
3837
  * The key `useWorkflowSubmit` uses when the page named none.
3386
3838
  *
3387
3839
  * Two things it does that a plain `useRunKey()` at the call site cannot, and
@@ -3401,12 +3853,15 @@ function mintRunKey(storage) {
3401
3853
  *
3402
3854
  * @param explicit - The caller's own key, or undefined for a page with none.
3403
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}.
3404
3858
  *
3405
3859
  * @internal
3406
3860
  */
3407
3861
  function useDefaultRunKey(explicit) {
3408
- const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
3409
- 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;
3410
3865
  }
3411
3866
  /**
3412
3867
  * A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
@@ -3431,7 +3886,8 @@ function useRunKey(options = {}) {
3431
3886
  * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
3432
3887
  * This is what comes before it: `useWorkflowSubmit` starts a run and hands the
3433
3888
  * id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
3434
- * `<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`).
3435
3891
  *
3436
3892
  * ## `useWorkflowSubmit` — a form's two halves in one hook
3437
3893
  *
@@ -3459,89 +3915,53 @@ function useRunKey(options = {}) {
3459
3915
  * followed from the same id either way.
3460
3916
  */
3461
3917
  /**
3462
- * Start a workflow from a form, and follow the run it creates.
3463
- *
3464
- * @typeParam D - The workflow DEFINITION, which types both halves of the
3465
- * submission: `submit(input)` takes what the workflow's schema parses to, and
3466
- * `run.status === "completed"` narrows to a typed `run.output`.
3467
- *
3468
- * It used to be the OUTPUT type alone, and the asymmetry was the bug: a page
3469
- * already wrote `WorkflowOutputOf<typeof digest>` to get the output, while
3470
- * `submit` took `unknown`, so `submit({ ur1: 42 })` compiled and arrived as a
3471
- * 400 in the browser. Naming the def instead types the input from the same
3472
- * declaration — and `import type` is ERASED, so it costs the bundle nothing.
3473
- * Passing an output type where a def belongs is now a compile error rather
3474
- * than a silent loss of typing, which is the point.
3475
- *
3476
- * @example
3477
- * ```tsx no-check
3478
- * import { Form, SubmitButton, TextField, useWorkflowSubmit } from "@alexkroman1/aai-ui";
3479
- * import type { digest } from "./agent.ts";
3480
- *
3481
- * function DigestForm() {
3482
- * const { submit, run, pending, error } = useWorkflowSubmit<typeof digest>("digest");
3483
- * return (
3484
- * <Form onSubmit={(values) => submit(values)} error={error}>
3485
- * <TextField name="url" label="Link" type="url" required />
3486
- * <SubmitButton pending={pending}>Digest</SubmitButton>
3487
- * {run?.status === "completed" && <p>{run.output.title}</p>}
3488
- * </Form>
3489
- * );
3490
- * }
3491
- * ```
3492
- *
3493
- * @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.
3494
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
+ }
3495
3928
  function useWorkflowSubmit(workflow, options = {}) {
3496
3929
  const { api, recover = true, wait, intervalMs, parallel } = options;
3497
3930
  const key = useDefaultRunKey(options.key);
3931
+ const scope = scopeOf(options.key, recover);
3498
3932
  const getClient = useWorkflowApiRef(api);
3499
- const form = useSubmissionState({
3500
- workflow,
3501
- key,
3502
- enabled: recover,
3503
- getClient
3504
- });
3505
- const { runId, actions } = form;
3506
- const tracked = useWorkflowRun(runId, omitUndefined({
3507
- api,
3508
- intervalMs
3509
- }));
3510
- const { wake, cancel } = useRunControls(runId, getClient);
3511
- const submit = useCallback((input) => {
3512
- const client = getClient();
3933
+ const begin = useCallback((input, client) => {
3513
3934
  const session = createUploadSession(workflow);
3514
- return actions.submit(session.gate, async ({ progress, started }) => {
3515
- const options = omitUndefined({ key });
3516
- const report = coalesceUploadReports(progress);
3517
- const stored = await uploadFiles(client, input, report, parallel, session);
3518
- started(wait === void 0 ? await client.start(workflow, stored, options) : (await client.startAndWait(workflow, stored, {
3519
- ...options,
3520
- wait
3521
- })).runId);
3522
- });
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
+ };
3523
3946
  }, [
3524
3947
  workflow,
3525
3948
  key,
3526
3949
  wait,
3527
- parallel,
3528
- getClient,
3529
- actions
3950
+ parallel
3530
3951
  ]);
3531
- return {
3532
- submit,
3533
- submitForm: submit,
3534
- reset: actions.reset,
3535
- wake,
3536
- cancel,
3537
- pauseUpload: actions.pauseUpload,
3538
- resumeUpload: actions.resumeUpload,
3539
- run: tracked.run,
3540
- startedHere: form.startedHere,
3541
- pending: form.busy || tracked.polling,
3542
- upload: form.upload,
3543
- error: form.startError ?? tracked.error
3544
- };
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
+ });
3545
3965
  }
3546
3966
  //#endregion
3547
3967
  //#region src/_page-shell.tsx
@@ -3835,7 +4255,7 @@ function phoneE164(typed, options = {}) {
3835
4255
  * @param method - The route's method.
3836
4256
  * @param path - The route's path as declared, starting with `/`; `?query` allowed.
3837
4257
  * @param body - Sent as JSON when given.
3838
- * @param options - `client`, `baseUrl` and `signal`; see {@link RouteFetchOptions}.
4258
+ * @param options - `client`, `baseUrl`, `headers` and `signal`; see {@link RouteFetchOptions}.
3839
4259
  * @returns The parsed JSON body.
3840
4260
  *
3841
4261
  * @public
@@ -3844,13 +4264,18 @@ async function routeFetch(method, path, body, options = {}) {
3844
4264
  const base = options.baseUrl ?? pageBaseUrl();
3845
4265
  const url = new URL(`api${path.startsWith("/") ? path : `/${path}`}`, base);
3846
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
+ });
3847
4272
  const res = await fetch(url, {
3848
4273
  method,
3849
- ...body === void 0 ? {} : {
3850
- headers: { "content-type": "application/json" },
3851
- body: JSON.stringify(body)
3852
- },
3853
- ...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
+ })
3854
4279
  });
3855
4280
  const json = await res.json().catch(() => ({}));
3856
4281
  if (!res.ok) {
@@ -3998,12 +4423,20 @@ function useStoredValue(source, initial) {
3998
4423
  * dropped — `useWorkflowRuns`' rule), a read landing after unmount, and a
3999
4424
  * failure rendered as an empty answer. `data` is KEPT through a failed re-read,
4000
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.
4001
4434
  *
4002
4435
  * @module
4003
4436
  */
4004
4437
  /**
4005
4438
  * `GET` one of the agent's own routes, on mount, on `reload()` and every
4006
- * `pollMs` — see this module's doc.
4439
+ * `pollMs` until `until` says so — see this module's doc.
4007
4440
  *
4008
4441
  * @example A list that re-reads after each delete
4009
4442
  * ```tsx
@@ -4033,9 +4466,24 @@ function useStoredValue(source, initial) {
4033
4466
  * }
4034
4467
  * ```
4035
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
+ *
4036
4484
  * @typeParam T - The shape the route answers. Not checked.
4037
4485
  * @param path - The route's path as declared, without `/api`. `null` reads nothing.
4038
- * @param options - `pollMs` and `client`; see {@link UseRouteOptions}.
4486
+ * @param options - `pollMs`, `until`, `client` and `headers`; see {@link UseRouteOptions}.
4039
4487
  * @returns The answer, the last error, and `reload`; see {@link UseRouteResult}.
4040
4488
  *
4041
4489
  * @public
@@ -4043,49 +4491,85 @@ function useStoredValue(source, initial) {
4043
4491
  function useRoute(path, options = {}) {
4044
4492
  const session = useOptionalSessionCore();
4045
4493
  const { pollMs, client } = options;
4046
- const [data, setData] = useState(void 0);
4047
- 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);
4048
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
+ };
4049
4505
  const epochRef = useRef(void 0);
4050
4506
  epochRef.current ??= createEpoch();
4051
4507
  const epoch = epochRef.current;
4052
- const reload = useCallback(() => {
4053
- 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;
4054
4518
  epoch.bump();
4055
4519
  const mine = epoch.current();
4056
4520
  setLoading(true);
4057
- const who = client ?? session?.identity.clientId();
4058
- routeFetch("GET", path, void 0, who ? { client: who } : {}).then((answer) => {
4059
- if (!epoch.isCurrent(mine)) return;
4060
- setData(answer);
4061
- 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
+ });
4062
4534
  setLoading(false);
4063
- }).catch((err) => {
4064
- if (!epoch.isCurrent(mine)) return;
4065
- 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
+ }));
4066
4543
  setLoading(false);
4067
- });
4544
+ return false;
4545
+ }
4068
4546
  }, [
4069
4547
  path,
4070
4548
  client,
4071
4549
  session,
4072
4550
  epoch
4073
4551
  ]);
4552
+ const reload = useCallback(() => {
4553
+ read();
4554
+ }, [read]);
4074
4555
  useEffect(() => {
4075
- reload();
4076
- 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());
4077
4560
  return () => {
4078
- clearInterval(timer);
4561
+ stop();
4079
4562
  epoch.bump();
4080
4563
  };
4081
4564
  }, [
4082
- reload,
4565
+ read,
4083
4566
  pollMs,
4084
4567
  epoch
4085
4568
  ]);
4569
+ const current = answer?.path === path ? answer : void 0;
4086
4570
  return {
4087
- data,
4088
- error,
4571
+ data: current?.data,
4572
+ error: current?.error,
4089
4573
  loading,
4090
4574
  reload
4091
4575
  };
@@ -4405,7 +4889,7 @@ function useConversationLog(options = {}) {
4405
4889
  own.current = sessionId;
4406
4890
  const mirrored = useRef(/* @__PURE__ */ new Map());
4407
4891
  const mirror = useCallback((event) => {
4408
- if (event.sessionId === own.current) return;
4892
+ if (event.type === "other" || event.sessionId === own.current) return;
4409
4893
  if (event.type === "session_ended") {
4410
4894
  mirrored.current.delete(event.sessionId);
4411
4895
  return;
@@ -4465,6 +4949,209 @@ function append(list, event) {
4465
4949
  return item ? [...list, item] : void 0;
4466
4950
  }
4467
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
4468
5155
  //#region src/use-download-url.ts
4469
5156
  /**
4470
5157
  * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
@@ -5325,24 +6012,6 @@ function useTapToTalk(options = {}) {
5325
6012
  * a page calls when its own run settles, which is exactly when the list is
5326
6013
  * stale.
5327
6014
  */
5328
- /**
5329
- * Read a workflow's recent runs.
5330
- *
5331
- * @typeParam R - The workflow's output type, so a completed run's `output` is
5332
- * typed rather than `unknown`. Derive it with `WorkflowOutputOf`.
5333
- *
5334
- * @example
5335
- * ```tsx
5336
- * import { useWorkflowRuns } from "@alexkroman1/aai-ui";
5337
- *
5338
- * function History() {
5339
- * const { runs } = useWorkflowRuns("transcribe", { limit: 10 });
5340
- * return <ul>{runs.map((run) => <li key={run.runId}>{run.status}</li>)}</ul>;
5341
- * }
5342
- * ```
5343
- *
5344
- * @public
5345
- */
5346
6015
  function useWorkflowRuns(workflow, options = {}) {
5347
6016
  const { api, limit, key, skip = false } = options;
5348
6017
  const [runs, setRuns] = useState([]);
@@ -5479,94 +6148,53 @@ function useWorkflowRuns(workflow, options = {}) {
5479
6148
  * `stream.ts`'s idle bound (five minutes of no new bytes) is what decides that a
5480
6149
  * pause has become an abandonment.
5481
6150
  */
5482
- /**
5483
- * Start a workflow run and stream a file into it while it works.
5484
- *
5485
- * The workflow declares which input property carries the upload
5486
- * (`workflow({ uploads: ["recording"] })`) — the same declaration
5487
- * `useWorkflowSubmit` reads, because what the property carries is an upload id
5488
- * either way. What differs is only WHEN the id becomes valid.
5489
- *
5490
- * @typeParam R - The workflow's output type, which is what makes
5491
- * `run.status === "completed"` narrow to a typed `run.output`. Derive it with
5492
- * `WorkflowOutputOf<typeof myWorkflow>`.
5493
- *
5494
- * @example
5495
- * ```tsx no-check
5496
- * const { submit, run, upload, pending, error } = useWorkflowStream("transcribe");
5497
- *
5498
- * <Form onSubmit={(values) => submit(values)} error={error}>
5499
- * <WorkflowFields workflow="transcribe" />
5500
- * <UploadProgressBar upload={upload} />
5501
- * <SubmitButton pending={pending}>Transcribe</SubmitButton>
5502
- * </Form>
5503
- * ```
5504
- *
5505
- * @public
5506
- */
5507
6151
  function useWorkflowStream(workflow, options = {}) {
5508
6152
  const { api, key, intervalMs, parallel } = options;
5509
- const getClient = useWorkflowApiRef(api);
5510
- const form = useSubmissionState();
5511
- const { runId, actions } = form;
5512
- const tracked = useWorkflowRun(runId, omitUndefined({
6153
+ return useSubmission({
6154
+ getClient: useWorkflowApiRef(api),
5513
6155
  api,
5514
- intervalMs
5515
- }));
5516
- const { wake, cancel } = useRunControls(runId, getClient);
5517
- const submit = useCallback((input) => {
5518
- const client = getClient();
5519
- const gate = createUploadGate();
5520
- return actions.submit(gate, async ({ progress, started: adopt }) => {
5521
- let started;
5522
- try {
5523
- const id = randomUploadId();
5524
- const begun = await beginRun({
5525
- client,
5526
- workflow,
5527
- input,
5528
- id,
5529
- ...omitUndefined({ key })
5530
- });
5531
- started = begun.runId;
5532
- const chosen = begun.file;
5533
- adopt(started);
5534
- if (!chosen) return;
5535
- await streamFile({
5536
- client,
5537
- gate,
5538
- id,
5539
- file: chosen,
5540
- parallel,
5541
- report: coalesceUploadReports(progress)
5542
- });
5543
- await client.wake(started).catch(() => void 0);
5544
- } catch (err) {
5545
- if (started) await client.cancel(started).catch(() => void 0);
5546
- throw err;
5547
- }
5548
- });
5549
- }, [
5550
- workflow,
5551
- key,
5552
- parallel,
5553
- getClient,
5554
- actions
5555
- ]);
5556
- return {
5557
- submit,
5558
- submitForm: submit,
5559
- reset: actions.reset,
5560
- wake,
5561
- cancel,
5562
- pauseUpload: actions.pauseUpload,
5563
- resumeUpload: actions.resumeUpload,
5564
- run: tracked.run,
5565
- startedHere: form.startedHere,
5566
- pending: form.busy || tracked.polling,
5567
- upload: form.upload,
5568
- error: form.startError ?? tracked.error
5569
- };
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
+ });
5570
6198
  }
5571
6199
  /**
5572
6200
  * Read the declaration, substitute the id, and start the run.
@@ -5661,4 +6289,4 @@ function fileAt(input, field) {
5661
6289
  return filesOf(input[field])[0];
5662
6290
  }
5663
6291
  //#endregion
5664
- 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, 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 };