@alexkroman1/aai-ui 16.1.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +3 -2
  2. package/dist/_page-shell.d.ts +11 -0
  3. package/dist/{chat-view-DsylXJsw.js → chat-view-P12pHKQU.js} +1 -1
  4. package/dist/{client-config-DJQHnYjm.js → client-config-CFhkMkmv.js} +15 -13
  5. package/dist/client-config.d.ts +14 -13
  6. package/dist/components/chat-view.js +1 -1
  7. package/dist/components/controls.js +1 -1
  8. package/dist/components/message-list.js +1 -1
  9. package/dist/components/tool-call-block.js +1 -1
  10. package/dist/context.d.ts +27 -5
  11. package/dist/context.js +3 -3
  12. package/dist/default-client/assets/index-5XG8PqGw.css +2 -0
  13. package/dist/default-client/assets/index-tc-nAWq3.js +121 -0
  14. package/dist/default-client/index.html +2 -2
  15. package/dist/default-client.d.ts +21 -0
  16. package/dist/define-client.js +4 -4
  17. package/dist/hooks.d.ts +19 -9
  18. package/dist/hooks.js +1 -1
  19. package/dist/index.d.ts +17 -13
  20. package/dist/index.js +734 -433
  21. package/dist/internal.js +3 -3
  22. package/dist/{message-list-BLyPRS1Q.js → message-list-CJX560b5.js} +1 -1
  23. package/dist/page.d.ts +47 -21
  24. package/dist/{session-core-gwePM95B.js → session-core-TOdm_Con.js} +424 -174
  25. package/dist/session-core-audio-effects.d.ts +26 -0
  26. package/dist/session-core-audio-setup.d.ts +38 -31
  27. package/dist/session-core-audio-state.d.ts +152 -0
  28. package/dist/session-core-messages.d.ts +9 -10
  29. package/dist/session-core-types.d.ts +60 -21
  30. package/dist/session-core-user-turn.d.ts +30 -0
  31. package/dist/session-core.js +1 -1
  32. package/dist/use-push-to-talk.d.ts +105 -0
  33. package/dist/{use-workflow-run-CNahvlgL.js → use-workflow-run-ZqJWJn2-.js} +24 -11
  34. package/dist/workflow-client.d.ts +26 -12
  35. package/package.json +2 -2
  36. package/dist/default-client/assets/index-CMh4E-Ci.css +0 -2
  37. package/dist/default-client/assets/index-eOmPq2sS.js +0 -284
  38. package/dist/{tool-call-block-C2t_5fpp.js → tool-call-block-4gXoL_eP.js} +1 -1
  39. package/dist/{url-chips-D62RTz7v.js → url-chips-0n35fPJY.js} +1 -1
package/dist/index.js CHANGED
@@ -1,24 +1,24 @@
1
- import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-DsylXJsw.js";
2
- import { n as fetchClientConfig } from "./client-config-DJQHnYjm.js";
3
- import { a as AutoScroll, i as useConversation, n as Markdown, r as ConversationView, t as MessageList } from "./message-list-BLyPRS1Q.js";
4
- import { ThemeProvider, useSession, useSessionActions, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
1
+ import { a as AGENT_STATE_LABELS, i as SessionErrorBanner, n as ConsoleShell, r as SessionStateDot, t as ChatView } from "./chat-view-P12pHKQU.js";
2
+ import { n as setPageTitle } from "./_utils-DnQDM9Uy.js";
3
+ import { n as fetchClientConfig } from "./client-config-CFhkMkmv.js";
4
+ import { a as AutoScroll, i as useConversation, n as Markdown, r as ConversationView, t as MessageList } from "./message-list-CJX560b5.js";
5
+ import { ThemeProvider, useSession, useSessionActions, useSessionCore, useSessionError, useSessionSelector, useSessionStatus, useTheme } from "./context.js";
5
6
  import { a as inkTint, i as focusRingStyle, n as FOCUS_RING } from "./_colors-CpZO-88A.js";
6
7
  import { Button } from "./components/button.js";
7
- import { n as setPageTitle } from "./_utils-DnQDM9Uy.js";
8
- import { a as useFlash, i as useCopy } from "./url-chips-D62RTz7v.js";
8
+ import { a as useFlash, i as useCopy } from "./url-chips-0n35fPJY.js";
9
9
  import { Controls } from "./components/controls.js";
10
10
  import { n as useUserTranscript } from "./use-user-transcript-uyHhzy4d.js";
11
- import { n as ToolCallRow } from "./tool-call-block-C2t_5fpp.js";
11
+ import { n as ToolCallRow } from "./tool-call-block-4gXoL_eP.js";
12
12
  import { SidebarLayout } from "./components/sidebar-layout.js";
13
13
  import { StartScreen } from "./components/start-screen.js";
14
- import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-CNahvlgL.js";
15
- import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-gwePM95B.js";
14
+ import { a as useWorkflowProgress, c as isTerminal, o as useWorkflowApiRef, r as useWorkflowRun, s as createWorkflowApi } from "./use-workflow-run-ZqJWJn2-.js";
15
+ import { i as urlSlot, n as storageGet, r as storageSet, t as createBrowserSession } from "./session-core-TOdm_Con.js";
16
16
  import { mountClient, mountRoot, resolveContainer } from "./define-client.js";
17
17
  import { useAgentState, useEvent, useToolCallStart, useToolResult } from "./hooks.js";
18
+ import { errorMessage } from "@alexkroman1/aai";
18
19
  import clsx from "clsx";
19
20
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
20
21
  import { Fragment as Fragment$1, createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
21
- import { errorMessage } from "@alexkroman1/aai";
22
22
  import { formatBytes, isRecord, omitUndefined } from "@alexkroman1/aai/utils";
23
23
  import { createEpoch } from "@alexkroman1/aai/internal";
24
24
  //#region src/components/audio-result.tsx
@@ -893,7 +893,7 @@ function SubmitButton({ children, pending = false, pendingLabel = "Working…",
893
893
  * unusual for even that component's render slot takes the hook alone.
894
894
  */
895
895
  const selectStarted = (snapshot) => snapshot.started;
896
- const selectRunning = (snapshot) => snapshot.running;
896
+ const selectRunning$1 = (snapshot) => snapshot.running;
897
897
  /**
898
898
  * The state and the actions a Start / Pause–Resume / New conversation / End row
899
899
  * renders from, on two one-field subscriptions.
@@ -923,7 +923,7 @@ const selectRunning = (snapshot) => snapshot.running;
923
923
  function useSessionControls() {
924
924
  const { start, toggle, restart, end } = useSessionActions();
925
925
  const started = useSessionSelector(selectStarted);
926
- const running = useSessionSelector(selectRunning);
926
+ const running = useSessionSelector(selectRunning$1);
927
927
  return useMemo(() => ({
928
928
  started,
929
929
  running,
@@ -1747,447 +1747,142 @@ function WorkflowRunPanel({ run, statusLabels, onClear, api, live, children, cla
1747
1747
  });
1748
1748
  }
1749
1749
  //#endregion
1750
- //#region src/page.tsx
1751
- /** @jsxImportSource react */
1750
+ //#region src/_recover-run.ts
1752
1751
  /**
1753
- * `mountPage()` — mount a WORKFLOW APP's UI: React, theme, no session.
1754
- *
1755
- * The twin of `mountClient()` for an agent whose front door is a form rather than a
1756
- * microphone (`workflowApp()`). It is a separate entry rather than
1757
- * an option on `mountClient()` because of what `mountClient()` unavoidably does: it
1758
- * constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
1759
- * graph, and a microphone request. A flag would have to make all of that
1760
- * conditional, and every session hook would then have to answer "what does this
1761
- * mean with no session?" — so the honest split is two mounts. A page that wants
1762
- * voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
1752
+ * Finding a run again when the page has lost its id.
1763
1753
  *
1764
- * Authoring is otherwise identical — the file is still `client.tsx`, still
1765
- * React, still Tailwind, still the same theme tokens — so a workflow app reads
1766
- * like every other agent. What it reaches for instead of `useSession()` is
1767
- * `createWorkflowApi()` / `useWorkflowRun()`.
1768
- */
1769
- /**
1770
- * Mount a page for an agent whose work happens in workflows.
1754
+ * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
1755
+ * which was only half true of the hooks above it: the run id lived in plain
1756
+ * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
1757
+ * live run with nothing anywhere able to name it. The run really did continue;
1758
+ * the person really could not get back to it.
1771
1759
  *
1772
- * There is deliberately no session, no microphone, and no socket: the component
1773
- * talks to the agent over the workflow HTTP API
1774
- * (`createWorkflowApi`/`useWorkflowRun`), which is durable and outlives the tab.
1760
+ * `StartOptions.key` is the handle that survives that, and it always was — a
1761
+ * caller's own name for a run, indexed by the agent, read back with
1762
+ * `find(workflow, key)`. What was missing is the two lines that ASK. This is
1763
+ * them, plus the four decisions they turn out to carry.
1775
1764
  *
1776
- * @example
1777
- * ```tsx
1778
- * import { createWorkflowApi, mountPage, useWorkflowRun } from "@alexkroman1/aai-ui";
1779
- * import { useState } from "react";
1765
+ * ## It is a MOUNT-time act, not "whenever there is no run"
1780
1766
  *
1781
- * // Hoisted: a client built in render is a new object every render.
1782
- * const api = createWorkflowApi();
1767
+ * The tempting spelling is "if we hold no run id, look one up", and it breaks
1768
+ * `reset()`: a form put back to its initial state holds no run id, so the next
1769
+ * pass would re-adopt the very run the person had just dismissed — a Clear
1770
+ * button that clears nothing. So the lookup runs once per mount (and again only
1771
+ * if the KEY changes, which is a different person's run), and every later
1772
+ * absence of a run id is taken at face value.
1783
1773
  *
1784
- * function App() {
1785
- * const [runId, setRunId] = useState<string>();
1786
- * const { run } = useWorkflowRun(runId, { api });
1787
- * return (
1788
- * <button
1789
- * type="button"
1790
- * onClick={() => void api.start("digest", { topic: "ai" }).then(setRunId)}
1791
- * >
1792
- * {run ? run.status : "Start"}
1793
- * </button>
1794
- * );
1795
- * }
1774
+ * ## The lookup NEVER wins a race against a submit
1796
1775
  *
1797
- * mountPage({ name: "Digest", component: App });
1798
- * ```
1776
+ * A person who reloads and immediately submits has started the run they want,
1777
+ * and an answer that was already in flight names an older one. The caller
1778
+ * therefore adopts through `current ?? found`: the recovered id fills an empty
1779
+ * slot and never replaces a full one.
1799
1780
  *
1800
- * @throws If the target element is not found in the DOM.
1781
+ * ## A failed lookup is REPORTED
1801
1782
  *
1802
- * @public
1803
- */
1804
- function mountPage(config) {
1805
- const container = resolveContainer(config.target);
1806
- setPageTitle(config.name);
1807
- return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, createElement(config.component)));
1808
- }
1809
- //#endregion
1810
- //#region src/use-download-url.ts
1811
- /**
1812
- * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
1813
- * `<img>` or `<a download>` will accept.
1783
+ * The alternative is a page that quietly shows an empty form to somebody whose
1784
+ * run is live, who then starts a second one — the duplicated work the key
1785
+ * exists to prevent, and on a workflow app that is real money. A person who has
1786
+ * never run anything pays a banner they can ignore. Same trade as
1787
+ * `useWorkflows`, for the same reason: an empty answer here is a confident
1788
+ * false statement.
1814
1789
  *
1815
- * `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
1816
- * same bearer every workflow route does, and neither `<audio src>` nor
1817
- * `<a href>` can send one. So every page that plays back what a run WROTE ends
1818
- * up at the same four lines — `download` → `createObjectURL` → state — and the
1819
- * two that are really the point are the two the four lines are wrapped in:
1790
+ * ## It is ON by default, and it used to be opt-in
1820
1791
  *
1821
- * - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
1822
- * life of the DOCUMENT. Miss it and every completed run's audio stays resident
1823
- * until the tab closes, which on a page people run all day is a leak measured
1824
- * in the size of the files.
1825
- * - **A `cancelled` flag.** A second run settling while the first download is
1826
- * still in flight otherwise sets state from the stale one, and the page plays
1827
- * the previous run's audio under the current run's transcript — a wrong answer
1828
- * that looks like a right one.
1792
+ * The argument for opt-in was that a `key` on its own means only "record this
1793
+ * with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
1794
+ * means, there being no page to put a run back on. A FORM is the other case: it
1795
+ * is the page, and losing the run is the thing it cannot recover from. Six of
1796
+ * six page templates wrote `useRunKey()` and `recover: true` together, which is
1797
+ * the same shape `session-resume-store.ts` names on the voice side — a default
1798
+ * in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
1829
1799
  *
1830
- * Two templates had written this hook, identically, doc paragraph included, and
1831
- * `aai-ui` exported no download helper at all. Both also faked `pending` by
1832
- * checking `url === undefined && error === undefined`, which reads "idle" and
1833
- * "downloading" as the same thing — so this reports it.
1800
+ * `enabled` remains, because `recover: false` remains: a page whose form must
1801
+ * always open empty says so, and then nothing here runs.
1834
1802
  */
1835
- /** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
1836
- const IDLE = { pending: false };
1837
- /** Bytes in flight. Shared for the same reason as {@link IDLE}. */
1838
- const PENDING = { pending: true };
1839
1803
  /**
1840
- * Read an upload's bytes and hand back a URL a DOM element can use.
1841
- *
1842
- * @param uploadId - The id a completed run reported, or `undefined` before one
1843
- * exists — which is what a page passes straight through while it waits, and
1844
- * reports as idle rather than pending.
1845
- * @param options - See {@link UseDownloadUrlOptions}.
1846
- * @returns See {@link UseDownloadUrlResult}.
1847
- *
1848
- * @example
1849
- * ```tsx no-check
1850
- * import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
1851
- * import type { spokenSummary } from "./agent.ts";
1804
+ * Look up the newest run for a key, once, as the component mounts.
1852
1805
  *
1853
- * function Playback() {
1854
- * const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
1855
- * const output = run?.status === "completed" ? run.output : undefined;
1856
- * const audio = useDownloadUrl(output?.audio);
1857
- * if (audio.pending) return <p>Fetching audio…</p>;
1858
- * if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
1859
- * return audio.url === undefined ? null : (
1860
- * <a href={audio.url} download="summary.mp3">
1861
- * Download
1862
- * </a>
1863
- * );
1864
- * }
1865
- * ```
1806
+ * @param opts - See {@link RecoverRunOptions}.
1807
+ * @returns Whether the lookup is still out. A caller folds it into its own
1808
+ * `pending`, because a form offering Submit while a live run is arriving is a
1809
+ * form inviting a second one.
1866
1810
  *
1867
- * @public
1811
+ * @internal
1868
1812
  */
1869
- function useDownloadUrl(uploadId, options = {}) {
1870
- const [state, setState] = useState(IDLE);
1871
- const getClient = useWorkflowApiRef(options.api);
1813
+ function useRecoveredRun(opts) {
1814
+ const { workflow, key, enabled, getClient } = opts;
1815
+ const [recovering, setRecovering] = useState(enabled);
1816
+ const handlers = useRef(opts);
1817
+ handlers.current = opts;
1872
1818
  useEffect(() => {
1873
- if (uploadId === void 0) {
1874
- setState(IDLE);
1875
- return;
1876
- }
1819
+ if (!enabled) return;
1877
1820
  let cancelled = false;
1878
- let objectUrl;
1879
- setState(PENDING);
1880
- getClient().download(uploadId).then((blob) => {
1821
+ setRecovering(true);
1822
+ getClient().find(workflow, key, { limit: 1 }).then((found) => {
1881
1823
  if (cancelled) return;
1882
- objectUrl = URL.createObjectURL(blob);
1883
- setState({
1884
- url: objectUrl,
1885
- pending: false
1886
- });
1824
+ const newest = found[0];
1825
+ if (newest !== void 0) handlers.current.onFound(newest.runId);
1826
+ setRecovering(false);
1887
1827
  }).catch((err) => {
1888
- if (!cancelled) setState({
1889
- error: errorMessage(err),
1890
- pending: false
1891
- });
1828
+ if (cancelled) return;
1829
+ handlers.current.onError(errorMessage(err));
1830
+ setRecovering(false);
1892
1831
  });
1893
1832
  return () => {
1894
1833
  cancelled = true;
1895
- if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
1896
1834
  };
1897
- }, [uploadId, getClient]);
1898
- return state;
1835
+ }, [
1836
+ enabled,
1837
+ key,
1838
+ workflow,
1839
+ getClient
1840
+ ]);
1841
+ return recovering;
1899
1842
  }
1900
1843
  //#endregion
1901
- //#region src/use-run-key.ts
1844
+ //#region src/_run-controls.ts
1902
1845
  /**
1903
- * The handle a page keeps on the runs it started, across a reload.
1904
- *
1905
- * `useWorkflowSubmit` is what makes a run survivable — the run id is that
1906
- * hook's own state, so a refresh loses it while the run carries on — and the
1907
- * `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
1908
- * there is no per-user filtering behind `find`, so the key IS the scoping
1909
- * mechanism. Choosing one is easy to get wrong in three separate ways, and six
1910
- * shipped templates had each written the same twenty lines to get it right.
1911
- * This is those lines.
1912
- *
1913
- * **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
1914
- * so a page resumes its own run across a reload with nothing written at the
1915
- * call site — six of six page templates passed `useRunKey()` and
1916
- * `recover: true`, which is a default in the wrong place. The hook stays
1917
- * PUBLIC for the page that wants to choose: an app with accounts passes the
1918
- * ACCOUNT's own id instead, and a run then follows the person to a new device,
1919
- * which is a promise only a login can keep; a page whose run outlives the tab
1920
- * passes `useRunKey({ storage: "local" })`.
1846
+ * The two things a page does TO a run it started, bound to the run it has.
1921
1847
  *
1922
- * ## Three properties, and the rejected alternatives are why each one matters
1848
+ * `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
1849
+ * handed it back, so a page that wanted "send it now" or "stop" had to hold an
1850
+ * `api` of its own purely to write `api.wake(runId)` — which is the whole reason
1851
+ * the two raw-primitive template pages keep a client at module scope. That is a
1852
+ * page carrying the transport to make up for a hook withholding its own state.
1923
1853
  *
1924
- * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
1925
- * A key derived from the input collides the moment two people submit the same
1926
- * thing, and they then recover each other's runs; it also carries what they
1927
- * typed into a lookup token the platform deliberately stopped logging.
1928
- * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
1929
- * `POST /workflows/runs` allows a key.
1930
- * - **Minted once per load and written back for the next one**, which is the
1931
- * whole mechanism: the load that presses the button records the key with the
1932
- * run, and the load after it finds the run by producing the same key.
1854
+ * Both calls answer rather than fail when there is nothing to act on — `0`
1855
+ * sleeps ended, `false` this call did not end it — which is the SDK's own
1856
+ * contract for them (two tabs pressing Stop is ordinary), and it is what lets
1857
+ * the no-run case be the same answer rather than a special one a caller has to
1858
+ * branch on.
1859
+ */
1860
+ /**
1861
+ * Bind `wake` and `cancel` to whatever run the hook is currently following.
1933
1862
  *
1934
- * Storage rather than the page's own URL, for all of them. A `?key=` parameter
1935
- * survives more (a new tab, a bookmark, a shared link) and that is the problem:
1936
- * a URL is pasted into chats, copied into referrers and kept in history, and
1937
- * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
1938
- * it. An app with accounts should pass the ACCOUNT's own id here instead, and
1939
- * then a run follows the person to a new device, which is a promise only a login
1940
- * can keep.
1863
+ * @param runId - The live run, or `undefined` before one exists.
1864
+ * @param getClient - The stable getter from `useWorkflowApiRef`.
1865
+ * @returns Two callbacks, stable while `runId` is.
1941
1866
  *
1942
- * ## The storage is the caller's decision, and it is not a detail
1943
- *
1944
- * `"session"` (the default) dies with the tab, which covers exactly the
1945
- * interruption most pages have — a reload, a same-tab navigation, a crashed tab
1946
- * — and is the same lifetime as this package's other two stores, the session
1947
- * resume id (`session-resume-store.ts`) and the upload recall
1948
- * (`_upload-recall.ts`), so both halves of a reload make the same promise.
1949
- *
1950
- * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
1951
- * between digests and may live a month, where closing the browser on Tuesday and
1952
- * coming back on Friday to press Stop is the ordinary case rather than an edge
1953
- * one, and a tab-scoped key would answer that with an empty form beside a run
1954
- * still posting somewhere. It is as far as a key can go without a login, and no
1955
- * further. `podcast-digest-workflow` is that template, and the reason this hook is still
1956
- * called by name anywhere; the other five take the tab-scoped default the
1957
- * submit hook mints for them.
1958
- *
1959
- * ## Anything ELSE a page stores back must be VALIDATED on read
1960
- *
1961
- * This key needs no validation, and it is worth saying why, because it is the
1962
- * exception: any string is a legal key, so a value from storage can only fail to
1963
- * match a run. A page that remembers something more — which MODE submitted, say
1964
- * — is remembering a value it will turn into a name, and storage hands back a
1965
- * string some earlier version of that page wrote: a renamed mode, a hand-edited
1966
- * value, a slot another app on the origin happens to share. Unchecked, that
1967
- * starts a run called `undefined` and answers a 400 nobody typed. Check it
1968
- * against the page's own list on the way out (`recalledMode` in
1969
- * `transcription-workflow/recover.ts` is the worked example) — the recall is the
1970
- * page's, the validation is not optional.
1971
- *
1972
- * ## The slot is keyed by the page's own URL
1973
- *
1974
- * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
1975
- * would have two agents scaffolded from the same template recover each other's
1976
- * runs. The key is the page's own directory — resolved through `"./"`, which
1977
- * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
1978
- * find the same key. Same call `session-resume-store.ts` makes, for the same
1979
- * reason.
1980
- *
1981
- * One key per PAGE is right even for a page driving several workflows: `find` is
1982
- * scoped by workflow as well as by key, so three hooks sharing one key recover
1983
- * three separate runs. `transcription-workflow` is that page.
1984
- *
1985
- * Every access is guarded. Storage THROWS outright in some contexts (Safari
1986
- * private mode, an iframe blocked by policy) and is ABSENT in others (any
1987
- * server-side render), and a page that cannot remember its key must degrade to
1988
- * the behaviour it would have had anyway — one run per load — rather than
1989
- * failing to render.
1990
- */
1991
- /** Where a run key lives, namespaced like this package's two other stores. */
1992
- const PREFIX$1 = "aai:run-key:";
1993
- /** This page's own slot — see "The slot is keyed by the page's own URL". */
1994
- function slotFor() {
1995
- return urlSlot(PREFIX$1, "./");
1996
- }
1997
- /**
1998
- * Read the key this page already has, or mint and remember one.
1999
- *
2000
- * Not exported: a page that wants a key wants it for the life of a component,
2001
- * which is what the hook is. Calling this per render would mint a fresh key and
2002
- * hand `recover` one nothing was ever started under.
2003
- */
2004
- function mintRunKey(storage) {
2005
- const slot = slotFor();
2006
- const stored = storageGet(storage, slot);
2007
- if (stored !== void 0) return stored;
2008
- const minted = crypto.randomUUID();
2009
- storageSet(storage, slot, minted);
2010
- return minted;
2011
- }
2012
- /**
2013
- * The key `useWorkflowSubmit` uses when the page named none.
2014
- *
2015
- * Two things it does that a plain `useRunKey()` at the call site cannot, and
2016
- * both are about a page that DID name one:
2017
- *
2018
- * - **It mints nothing when the caller has a key.** Minting writes to storage,
2019
- * so an unconditional `useRunKey()` inside the hook would leave a slot behind
2020
- * on every page that passes an account id and never reads it back.
2021
- * - **It stays reactive to the caller's key.** A key that arrives late — an
2022
- * account id resolved after a login — must reach the lookup, which re-asks on
2023
- * a changed key by design; freezing it into `useState` would pin the page to
2024
- * whatever it held on its first render.
2025
- *
2026
- * The minted half is still frozen for the component's life, which is what
2027
- * `useRunKey` freezes it for: a fresh key per render would record every run
2028
- * under a name the next load cannot produce.
2029
- *
2030
- * @param explicit - The caller's own key, or undefined for a page with none.
2031
- * @returns The key to record runs under and look them up by.
2032
- *
2033
- * @internal
2034
- */
2035
- function useDefaultRunKey(explicit) {
2036
- const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
2037
- return explicit ?? minted;
2038
- }
2039
- /**
2040
- * A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
2041
- *
2042
- * @param options - See the module doc for the whole argument. The storage kind
2043
- * is read once, when the key is minted: a value that changed afterwards would
2044
- * be asking to move a key that has already been recorded with a run.
2045
- * @returns The key to record runs under and to look them up by — the same one
2046
- * for the life of the component, and for the next load in the same tab (or the
2047
- * same browser, under `"local"`).
2048
- */
2049
- function useRunKey(options = {}) {
2050
- const { storage = "session" } = options;
2051
- const [key] = useState(() => mintRunKey(storage));
2052
- return key;
2053
- }
2054
- //#endregion
2055
- //#region src/_recover-run.ts
2056
- /**
2057
- * Finding a run again when the page has lost its id.
2058
- *
2059
- * A run is durable and a page is not — which `useWorkflowRun`'s doc says, and
2060
- * which was only half true of the hooks above it: the run id lived in plain
2061
- * `useState`, so a refresh (or a same-tab navigation, or a crashed tab) left a
2062
- * live run with nothing anywhere able to name it. The run really did continue;
2063
- * the person really could not get back to it.
2064
- *
2065
- * `StartOptions.key` is the handle that survives that, and it always was — a
2066
- * caller's own name for a run, indexed by the agent, read back with
2067
- * `find(workflow, key)`. What was missing is the two lines that ASK. This is
2068
- * them, plus the four decisions they turn out to carry.
2069
- *
2070
- * ## It is a MOUNT-time act, not "whenever there is no run"
2071
- *
2072
- * The tempting spelling is "if we hold no run id, look one up", and it breaks
2073
- * `reset()`: a form put back to its initial state holds no run id, so the next
2074
- * pass would re-adopt the very run the person had just dismissed — a Clear
2075
- * button that clears nothing. So the lookup runs once per mount (and again only
2076
- * if the KEY changes, which is a different person's run), and every later
2077
- * absence of a run id is taken at face value.
2078
- *
2079
- * ## The lookup NEVER wins a race against a submit
2080
- *
2081
- * A person who reloads and immediately submits has started the run they want,
2082
- * and an answer that was already in flight names an older one. The caller
2083
- * therefore adopts through `current ?? found`: the recovered id fills an empty
2084
- * slot and never replaces a full one.
2085
- *
2086
- * ## A failed lookup is REPORTED
2087
- *
2088
- * The alternative is a page that quietly shows an empty form to somebody whose
2089
- * run is live, who then starts a second one — the duplicated work the key
2090
- * exists to prevent, and on a workflow app that is real money. A person who has
2091
- * never run anything pays a banner they can ignore. Same trade as
2092
- * `useWorkflows`, for the same reason: an empty answer here is a confident
2093
- * false statement.
2094
- *
2095
- * ## It is ON by default, and it used to be opt-in
2096
- *
2097
- * The argument for opt-in was that a `key` on its own means only "record this
2098
- * with the run" — which is what a voice agent's `ctx.workflows.start({ key })`
2099
- * means, there being no page to put a run back on. A FORM is the other case: it
2100
- * is the page, and losing the run is the thing it cannot recover from. Six of
2101
- * six page templates wrote `useRunKey()` and `recover: true` together, which is
2102
- * the same shape `session-resume-store.ts` names on the voice side — a default
2103
- * in the wrong place — so `useWorkflowSubmit` now mints the key and asks.
2104
- *
2105
- * `enabled` remains, because `recover: false` remains: a page whose form must
2106
- * always open empty says so, and then nothing here runs.
2107
- */
2108
- /**
2109
- * Look up the newest run for a key, once, as the component mounts.
2110
- *
2111
- * @param opts - See {@link RecoverRunOptions}.
2112
- * @returns Whether the lookup is still out. A caller folds it into its own
2113
- * `pending`, because a form offering Submit while a live run is arriving is a
2114
- * form inviting a second one.
2115
- *
2116
- * @internal
2117
- */
2118
- function useRecoveredRun(opts) {
2119
- const { workflow, key, enabled, getClient } = opts;
2120
- const [recovering, setRecovering] = useState(enabled);
2121
- const handlers = useRef(opts);
2122
- handlers.current = opts;
2123
- useEffect(() => {
2124
- if (!enabled) return;
2125
- let cancelled = false;
2126
- setRecovering(true);
2127
- getClient().find(workflow, key, { limit: 1 }).then((found) => {
2128
- if (cancelled) return;
2129
- const newest = found[0];
2130
- if (newest !== void 0) handlers.current.onFound(newest.runId);
2131
- setRecovering(false);
2132
- }).catch((err) => {
2133
- if (cancelled) return;
2134
- handlers.current.onError(errorMessage(err));
2135
- setRecovering(false);
2136
- });
2137
- return () => {
2138
- cancelled = true;
2139
- };
2140
- }, [
2141
- enabled,
2142
- key,
2143
- workflow,
2144
- getClient
2145
- ]);
2146
- return recovering;
2147
- }
2148
- //#endregion
2149
- //#region src/_run-controls.ts
2150
- /**
2151
- * The two things a page does TO a run it started, bound to the run it has.
2152
- *
2153
- * `useWorkflowSubmit` and `useWorkflowStream` both hold a run id and neither
2154
- * handed it back, so a page that wanted "send it now" or "stop" had to hold an
2155
- * `api` of its own purely to write `api.wake(runId)` — which is the whole reason
2156
- * the two raw-primitive template pages keep a client at module scope. That is a
2157
- * page carrying the transport to make up for a hook withholding its own state.
2158
- *
2159
- * Both calls answer rather than fail when there is nothing to act on — `0`
2160
- * sleeps ended, `false` this call did not end it — which is the SDK's own
2161
- * contract for them (two tabs pressing Stop is ordinary), and it is what lets
2162
- * the no-run case be the same answer rather than a special one a caller has to
2163
- * branch on.
2164
- */
2165
- /**
2166
- * Bind `wake` and `cancel` to whatever run the hook is currently following.
2167
- *
2168
- * @param runId - The live run, or `undefined` before one exists.
2169
- * @param getClient - The stable getter from `useWorkflowApiRef`.
2170
- * @returns Two callbacks, stable while `runId` is.
2171
- *
2172
- * @internal
2173
- */
2174
- function useRunControls(runId, getClient) {
2175
- return {
2176
- wake: useCallback(async () => {
2177
- if (runId === void 0) return 0;
2178
- return await getClient().wake(runId);
2179
- }, [runId, getClient]),
2180
- cancel: useCallback(async () => {
2181
- if (runId === void 0) return false;
2182
- return await getClient().cancel(runId);
2183
- }, [runId, getClient])
2184
- };
2185
- }
2186
- //#endregion
2187
- //#region src/_upload-pause.ts
2188
- /**
2189
- * The two buttons a page puts over a live upload, bound to whichever gate the
2190
- * submission is currently holding.
1867
+ * @internal
1868
+ */
1869
+ function useRunControls(runId, getClient) {
1870
+ return {
1871
+ wake: useCallback(async () => {
1872
+ if (runId === void 0) return 0;
1873
+ return await getClient().wake(runId);
1874
+ }, [runId, getClient]),
1875
+ cancel: useCallback(async () => {
1876
+ if (runId === void 0) return false;
1877
+ return await getClient().cancel(runId);
1878
+ }, [runId, getClient])
1879
+ };
1880
+ }
1881
+ //#endregion
1882
+ //#region src/_upload-pause.ts
1883
+ /**
1884
+ * The two buttons a page puts over a live upload, bound to whichever gate the
1885
+ * submission is currently holding.
2191
1886
  *
2192
1887
  * `useWorkflowSubmit` and `useWorkflowStream` both own an {@link UploadGate} and
2193
1888
  * both reported the pause the same way: park the gate, then fold `paused` into
@@ -2317,7 +2012,7 @@ function useSubmissionState() {
2317
2012
  * under a blocking policy, and an upload that cannot be REMEMBERED must degrade
2318
2013
  * to the upload we would have done anyway rather than failing to start.
2319
2014
  */
2320
- const PREFIX = "aai:upload:";
2015
+ const PREFIX$1 = "aai:upload:";
2321
2016
  /**
2322
2017
  * How many ids one form keeps.
2323
2018
  *
@@ -2329,7 +2024,7 @@ const PREFIX = "aai:upload:";
2329
2024
  const MAX_REMEMBERED = 32;
2330
2025
  /** One form's slot in storage. */
2331
2026
  function keyFor(scope) {
2332
- return `${PREFIX}${scope}`;
2027
+ return `${PREFIX$1}${scope}`;
2333
2028
  }
2334
2029
  /**
2335
2030
  * What names this file across a reload.
@@ -2736,16 +2431,170 @@ function coalesceUploadReports(set) {
2736
2431
  };
2737
2432
  }
2738
2433
  //#endregion
2739
- //#region src/use-workflow-form.ts
2434
+ //#region src/use-run-key.ts
2740
2435
  /**
2741
- * The hook a FORM needs, as against the one a status view does.
2742
- *
2743
- * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
2744
- * This is what comes before it: `useWorkflowSubmit` starts a run and hands the
2745
- * id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
2746
- * `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
2747
- *
2748
- * ## `useWorkflowSubmit` — a form's two halves in one hook
2436
+ * The handle a page keeps on the runs it started, across a reload.
2437
+ *
2438
+ * `useWorkflowSubmit` is what makes a run survivable — the run id is that
2439
+ * hook's own state, so a refresh loses it while the run carries on — and the
2440
+ * `key` is what it looks the run up BY, because it is a lookup CAPABILITY:
2441
+ * there is no per-user filtering behind `find`, so the key IS the scoping
2442
+ * mechanism. Choosing one is easy to get wrong in three separate ways, and six
2443
+ * shipped templates had each written the same twenty lines to get it right.
2444
+ * This is those lines.
2445
+ *
2446
+ * **`useWorkflowSubmit` now mints one for itself** ({@link useDefaultRunKey}),
2447
+ * so a page resumes its own run across a reload with nothing written at the
2448
+ * call site — six of six page templates passed `useRunKey()` and
2449
+ * `recover: true`, which is a default in the wrong place. The hook stays
2450
+ * PUBLIC for the page that wants to choose: an app with accounts passes the
2451
+ * ACCOUNT's own id instead, and a run then follows the person to a new device,
2452
+ * which is a promise only a login can keep; a page whose run outlives the tab
2453
+ * passes `useRunKey({ storage: "local" })`.
2454
+ *
2455
+ * ## Three properties, and the rejected alternatives are why each one matters
2456
+ *
2457
+ * - **Opaque** — `crypto.randomUUID()`, never derived from what was submitted.
2458
+ * A key derived from the input collides the moment two people submit the same
2459
+ * thing, and they then recover each other's runs; it also carries what they
2460
+ * typed into a lookup token the platform deliberately stopped logging.
2461
+ * - **Short.** A `randomUUID` is 36 characters, well inside the 256 that
2462
+ * `POST /workflows/runs` allows a key.
2463
+ * - **Minted once per load and written back for the next one**, which is the
2464
+ * whole mechanism: the load that presses the button records the key with the
2465
+ * run, and the load after it finds the run by producing the same key.
2466
+ *
2467
+ * Storage rather than the page's own URL, for all of them. A `?key=` parameter
2468
+ * survives more (a new tab, a bookmark, a shared link) and that is the problem:
2469
+ * a URL is pasted into chats, copied into referrers and kept in history, and
2470
+ * what a leaked one buys is somebody else's work — reading it, and `cancel()` on
2471
+ * it. An app with accounts should pass the ACCOUNT's own id here instead, and
2472
+ * then a run follows the person to a new device, which is a promise only a login
2473
+ * can keep.
2474
+ *
2475
+ * ## The storage is the caller's decision, and it is not a detail
2476
+ *
2477
+ * `"session"` (the default) dies with the tab, which covers exactly the
2478
+ * interruption most pages have — a reload, a same-tab navigation, a crashed tab
2479
+ * — and is the same lifetime as this package's other two stores, the session
2480
+ * resume id (`session-resume-store.ts`) and the upload recall
2481
+ * (`_upload-recall.ts`), so both halves of a reload make the same promise.
2482
+ *
2483
+ * `"local"` is for a run that outlives all of that BY DESIGN — one that sleeps
2484
+ * between digests and may live a month, where closing the browser on Tuesday and
2485
+ * coming back on Friday to press Stop is the ordinary case rather than an edge
2486
+ * one, and a tab-scoped key would answer that with an empty form beside a run
2487
+ * still posting somewhere. It is as far as a key can go without a login, and no
2488
+ * further. `podcast-digest-workflow` is that template, and the reason this hook is still
2489
+ * called by name anywhere; the other five take the tab-scoped default the
2490
+ * submit hook mints for them.
2491
+ *
2492
+ * ## Anything ELSE a page stores back must be VALIDATED on read
2493
+ *
2494
+ * This key needs no validation, and it is worth saying why, because it is the
2495
+ * exception: any string is a legal key, so a value from storage can only fail to
2496
+ * match a run. A page that remembers something more — which MODE submitted, say
2497
+ * — is remembering a value it will turn into a name, and storage hands back a
2498
+ * string some earlier version of that page wrote: a renamed mode, a hand-edited
2499
+ * value, a slot another app on the origin happens to share. Unchecked, that
2500
+ * starts a run called `undefined` and answers a 400 nobody typed. Check it
2501
+ * against the page's own list on the way out (`recalledMode` in
2502
+ * `transcription-workflow/recover.ts` is the worked example) — the recall is the
2503
+ * page's, the validation is not optional.
2504
+ *
2505
+ * ## The slot is keyed by the page's own URL
2506
+ *
2507
+ * Every deployed agent is served from one origin at `/:slug/`, so a fixed name
2508
+ * would have two agents scaffolded from the same template recover each other's
2509
+ * runs. The key is the page's own directory — resolved through `"./"`, which
2510
+ * drops the query and the hash, since a reload carrying `?foo` or `#bar` has to
2511
+ * find the same key. Same call `session-resume-store.ts` makes, for the same
2512
+ * reason.
2513
+ *
2514
+ * One key per PAGE is right even for a page driving several workflows: `find` is
2515
+ * scoped by workflow as well as by key, so three hooks sharing one key recover
2516
+ * three separate runs. `transcription-workflow` is that page.
2517
+ *
2518
+ * Every access is guarded. Storage THROWS outright in some contexts (Safari
2519
+ * private mode, an iframe blocked by policy) and is ABSENT in others (any
2520
+ * server-side render), and a page that cannot remember its key must degrade to
2521
+ * the behaviour it would have had anyway — one run per load — rather than
2522
+ * failing to render.
2523
+ */
2524
+ /** Where a run key lives, namespaced like this package's two other stores. */
2525
+ const PREFIX = "aai:run-key:";
2526
+ /** This page's own slot — see "The slot is keyed by the page's own URL". */
2527
+ function slotFor() {
2528
+ return urlSlot(PREFIX, "./");
2529
+ }
2530
+ /**
2531
+ * Read the key this page already has, or mint and remember one.
2532
+ *
2533
+ * Not exported: a page that wants a key wants it for the life of a component,
2534
+ * which is what the hook is. Calling this per render would mint a fresh key and
2535
+ * hand `recover` one nothing was ever started under.
2536
+ */
2537
+ function mintRunKey(storage) {
2538
+ const slot = slotFor();
2539
+ const stored = storageGet(storage, slot);
2540
+ if (stored !== void 0) return stored;
2541
+ const minted = crypto.randomUUID();
2542
+ storageSet(storage, slot, minted);
2543
+ return minted;
2544
+ }
2545
+ /**
2546
+ * The key `useWorkflowSubmit` uses when the page named none.
2547
+ *
2548
+ * Two things it does that a plain `useRunKey()` at the call site cannot, and
2549
+ * both are about a page that DID name one:
2550
+ *
2551
+ * - **It mints nothing when the caller has a key.** Minting writes to storage,
2552
+ * so an unconditional `useRunKey()` inside the hook would leave a slot behind
2553
+ * on every page that passes an account id and never reads it back.
2554
+ * - **It stays reactive to the caller's key.** A key that arrives late — an
2555
+ * account id resolved after a login — must reach the lookup, which re-asks on
2556
+ * a changed key by design; freezing it into `useState` would pin the page to
2557
+ * whatever it held on its first render.
2558
+ *
2559
+ * The minted half is still frozen for the component's life, which is what
2560
+ * `useRunKey` freezes it for: a fresh key per render would record every run
2561
+ * under a name the next load cannot produce.
2562
+ *
2563
+ * @param explicit - The caller's own key, or undefined for a page with none.
2564
+ * @returns The key to record runs under and look them up by.
2565
+ *
2566
+ * @internal
2567
+ */
2568
+ function useDefaultRunKey(explicit) {
2569
+ const [minted] = useState(() => explicit === void 0 ? mintRunKey("session") : "");
2570
+ return explicit ?? minted;
2571
+ }
2572
+ /**
2573
+ * A lookup key for `useWorkflowSubmit({ key })`, stable across reloads.
2574
+ *
2575
+ * @param options - See the module doc for the whole argument. The storage kind
2576
+ * is read once, when the key is minted: a value that changed afterwards would
2577
+ * be asking to move a key that has already been recorded with a run.
2578
+ * @returns The key to record runs under and to look them up by — the same one
2579
+ * for the life of the component, and for the next load in the same tab (or the
2580
+ * same browser, under `"local"`).
2581
+ */
2582
+ function useRunKey(options = {}) {
2583
+ const { storage = "session" } = options;
2584
+ const [key] = useState(() => mintRunKey(storage));
2585
+ return key;
2586
+ }
2587
+ //#endregion
2588
+ //#region src/use-workflow-form.ts
2589
+ /**
2590
+ * The hook a FORM needs, as against the one a status view does.
2591
+ *
2592
+ * `useWorkflowRun` (`workflow-client.ts`) watches a run you already have.
2593
+ * This is what comes before it: `useWorkflowSubmit` starts a run and hands the
2594
+ * id straight to `useWorkflowRun`. Its sibling `useWorkflows` — the listing
2595
+ * `<WorkflowFields>` renders a form from — is `use-workflows.ts`.
2596
+ *
2597
+ * ## `useWorkflowSubmit` — a form's two halves in one hook
2749
2598
  *
2750
2599
  * A page that submits a workflow always needs the same four pieces of state:
2751
2600
  * the run id, whether a submit is in flight, whether the RUN is still going, and
@@ -2870,6 +2719,458 @@ function useWorkflowSubmit(workflow, options = {}) {
2870
2719
  };
2871
2720
  }
2872
2721
  //#endregion
2722
+ //#region src/_page-shell.tsx
2723
+ /**
2724
+ * The agent's own `name` and `greeting`, once the lookup lands.
2725
+ *
2726
+ * **Not skipped when the mount named the agent**, which is where this differs
2727
+ * from `mountClient()`'s `DefaultRoot`: there the response's only consumer is
2728
+ * the name fallback, so an explicit `name` made the request pure waste. Here the
2729
+ * GREETING is rendered too, and no argument to `mountPage()` supplies it.
2730
+ *
2731
+ * Every failure path already degrades to "the agent declared nothing" —
2732
+ * `fetchClientConfig` owns that — so there is nothing to report and nothing to
2733
+ * catch.
2734
+ */
2735
+ function useAgentConfig() {
2736
+ const [config, setConfig] = useState();
2737
+ useEffect(() => {
2738
+ let cancelled = false;
2739
+ fetchClientConfig().then((answer) => {
2740
+ if (!cancelled) setConfig(answer);
2741
+ });
2742
+ return () => {
2743
+ cancelled = true;
2744
+ };
2745
+ }, []);
2746
+ return config;
2747
+ }
2748
+ /**
2749
+ * One workflow's form, its run, and what the run said.
2750
+ *
2751
+ * Its own component because `useWorkflowSubmit` is per WORKFLOW: the hook holds
2752
+ * the run id, so switching workflows has to start over rather than show one
2753
+ * workflow's run under another's form. The shell below mounts this with
2754
+ * `key={name}`, which is what makes that reset happen.
2755
+ */
2756
+ function WorkflowRunner({ workflow }) {
2757
+ const { submitForm, run, pending, upload, pauseUpload, resumeUpload, error } = useWorkflowSubmit(workflow.name);
2758
+ return /* @__PURE__ */ jsxs("section", {
2759
+ className: "flex flex-col gap-4",
2760
+ children: [
2761
+ workflow.description === void 0 ? null : /* @__PURE__ */ jsx("p", {
2762
+ className: "text-sm opacity-70",
2763
+ children: workflow.description
2764
+ }),
2765
+ /* @__PURE__ */ jsxs(Form, {
2766
+ onSubmit: (values) => submitForm(values),
2767
+ error,
2768
+ className: "flex flex-col gap-4",
2769
+ children: [/* @__PURE__ */ jsx(WorkflowFields, { workflow }), /* @__PURE__ */ jsx(SubmitButton, {
2770
+ pending,
2771
+ children: "Start"
2772
+ })]
2773
+ }),
2774
+ /* @__PURE__ */ jsx(UploadProgressBar, {
2775
+ upload,
2776
+ onPause: pauseUpload,
2777
+ onResume: resumeUpload
2778
+ }),
2779
+ /* @__PURE__ */ jsx(WorkflowProgress, { runId: run?.runId }),
2780
+ /* @__PURE__ */ jsx(WorkflowRunError, { run }),
2781
+ run?.status === "completed" && /* @__PURE__ */ jsx("pre", {
2782
+ className: "overflow-x-auto rounded-md border p-4 text-xs",
2783
+ children: JSON.stringify(run.output, null, 2)
2784
+ })
2785
+ ]
2786
+ });
2787
+ }
2788
+ /**
2789
+ * The default page: the agent, its workflows, and one form.
2790
+ *
2791
+ * A picker appears only when there is more than one workflow — an agent with a
2792
+ * single workflow is the common case and a select with one option is chrome
2793
+ * about nothing.
2794
+ */
2795
+ function DefaultPageShell({ name }) {
2796
+ const config = useAgentConfig();
2797
+ const { workflows, loading, error } = useWorkflows();
2798
+ const [picked, setPicked] = useState();
2799
+ const selected = workflows.find((entry) => entry.name === picked) ?? workflows[0];
2800
+ return /* @__PURE__ */ jsxs("main", {
2801
+ className: "mx-auto flex max-w-2xl flex-col gap-6 p-8",
2802
+ children: [
2803
+ /* @__PURE__ */ jsxs("header", {
2804
+ className: "flex flex-col gap-1",
2805
+ children: [/* @__PURE__ */ jsx("h1", {
2806
+ className: "text-2xl font-medium",
2807
+ children: name ?? config?.name ?? "Workflows"
2808
+ }), config?.greeting === void 0 ? null : /* @__PURE__ */ jsx("p", {
2809
+ className: "text-sm opacity-70",
2810
+ children: config.greeting
2811
+ })]
2812
+ }),
2813
+ error === void 0 ? null : /* @__PURE__ */ jsx("p", {
2814
+ role: "alert",
2815
+ className: "text-red-600",
2816
+ children: error
2817
+ }),
2818
+ loading && /* @__PURE__ */ jsx("p", {
2819
+ className: "text-sm opacity-70",
2820
+ children: "Loading workflows…"
2821
+ }),
2822
+ !loading && error === void 0 && workflows.length === 0 && /* @__PURE__ */ jsx("p", {
2823
+ className: "text-sm opacity-70",
2824
+ children: "This agent declares no workflows."
2825
+ }),
2826
+ workflows.length > 1 && /* @__PURE__ */ jsxs("label", {
2827
+ className: "flex flex-col gap-1 text-sm",
2828
+ children: ["Workflow", /* @__PURE__ */ jsx("select", {
2829
+ value: selected?.name ?? "",
2830
+ onChange: (event) => setPicked(event.target.value),
2831
+ className: "rounded-md border px-3 py-2",
2832
+ children: workflows.map((entry) => /* @__PURE__ */ jsx("option", {
2833
+ value: entry.name,
2834
+ children: entry.name
2835
+ }, entry.name))
2836
+ })]
2837
+ }),
2838
+ selected && /* @__PURE__ */ jsx(WorkflowRunner, { workflow: selected }, selected.name)
2839
+ ]
2840
+ });
2841
+ }
2842
+ //#endregion
2843
+ //#region src/page.tsx
2844
+ /** @jsxImportSource react */
2845
+ /**
2846
+ * `mountPage()` — mount a WORKFLOW APP's UI: React, theme, no session.
2847
+ *
2848
+ * The twin of `mountClient()` for an agent whose front door is a form rather than a
2849
+ * microphone (`workflowApp()`). It is a separate entry rather than
2850
+ * an option on `mountClient()` because of what `mountClient()` unavoidably does: it
2851
+ * constructs a `BrowserSession`, which owns a WebSocket URL provider, an audio
2852
+ * graph, and a microphone request. A flag would have to make all of that
2853
+ * conditional, and every session hook would then have to answer "what does this
2854
+ * mean with no session?" — so the honest split is two mounts. A page that wants
2855
+ * voice uses `mountClient()`; a page that wants neither audio nor a socket uses this.
2856
+ *
2857
+ * Authoring is otherwise identical — the file is still `client.tsx`, still
2858
+ * React, still Tailwind, still the same theme tokens — so a workflow app reads
2859
+ * like every other agent. What it reaches for instead of `useSession()` is
2860
+ * `useWorkflowSubmit()` / `useWorkflowRun()`, neither of which needs a client
2861
+ * built for it.
2862
+ *
2863
+ * And `component` is OPTIONAL, the way `mountClient()`'s is: with none, this
2864
+ * mounts the generated shell in `_page-shell.tsx`. That is also what a workflow
2865
+ * app with NO `client.tsx` at all now gets — `default-client.tsx`, the prebuilt
2866
+ * page, branches on the agent's declared front door and mounts this.
2867
+ */
2868
+ /**
2869
+ * Mount a page for an agent whose work happens in workflows.
2870
+ *
2871
+ * There is deliberately no session, no microphone, and no socket: the page talks
2872
+ * to the agent over the workflow HTTP API (`useWorkflowSubmit`/`useWorkflowRun`),
2873
+ * which is durable and outlives the tab.
2874
+ *
2875
+ * **Config only:** leave `component` out and the generated shell renders — a
2876
+ * form per declared workflow, the run's progress, its failure, its output.
2877
+ *
2878
+ * **A custom component:** pass `component` and it is rendered inside the theme
2879
+ * provider instead. The pieces the default shell is built from are all published
2880
+ * (`useWorkflows`, `<WorkflowFields>`, `useWorkflowSubmit`,
2881
+ * `<WorkflowProgress>`, `<WorkflowRunError>`), so replacing the shell does not
2882
+ * mean starting from `fetch`.
2883
+ *
2884
+ * @example The default shell
2885
+ * ```tsx
2886
+ * import { mountPage } from "@alexkroman1/aai-ui";
2887
+ *
2888
+ * mountPage({ name: "Digest" });
2889
+ * ```
2890
+ *
2891
+ * @example A custom component
2892
+ * ```tsx
2893
+ * import { mountPage, useWorkflows } from "@alexkroman1/aai-ui";
2894
+ *
2895
+ * function App() {
2896
+ * // No client to build and none to hoist: every workflow hook defaults to one
2897
+ * // aimed at the agent serving this page, built lazily and once.
2898
+ * const { workflows, loading } = useWorkflows();
2899
+ * if (loading) return <p>Loading…</p>;
2900
+ * return (
2901
+ * <ul>
2902
+ * {workflows.map((entry) => (
2903
+ * <li key={entry.name}>{entry.description ?? entry.name}</li>
2904
+ * ))}
2905
+ * </ul>
2906
+ * );
2907
+ * }
2908
+ *
2909
+ * mountPage({ name: "Digest", component: App });
2910
+ * ```
2911
+ *
2912
+ * @throws If the target element is not found in the DOM.
2913
+ *
2914
+ * @public
2915
+ */
2916
+ function mountPage(config = {}) {
2917
+ const container = resolveContainer(config.target);
2918
+ setPageTitle(config.name);
2919
+ const Root = config.component;
2920
+ return mountRoot(container, createElement(ThemeProvider, { value: config.theme }, Root ? createElement(Root) : createElement(DefaultPageShell, { name: config.name })));
2921
+ }
2922
+ //#endregion
2923
+ //#region src/use-download-url.ts
2924
+ /**
2925
+ * `useDownloadUrl` — an upload id a run produced, as something `<audio>`,
2926
+ * `<img>` or `<a download>` will accept.
2927
+ *
2928
+ * `api.download(id)` resolves a `Blob`, and it has to: the byte route takes the
2929
+ * same bearer every workflow route does, and neither `<audio src>` nor
2930
+ * `<a href>` can send one. So every page that plays back what a run WROTE ends
2931
+ * up at the same four lines — `download` → `createObjectURL` → state — and the
2932
+ * two that are really the point are the two the four lines are wrapped in:
2933
+ *
2934
+ * - **`URL.revokeObjectURL` on cleanup.** An object URL pins its blob for the
2935
+ * life of the DOCUMENT. Miss it and every completed run's audio stays resident
2936
+ * until the tab closes, which on a page people run all day is a leak measured
2937
+ * in the size of the files.
2938
+ * - **A `cancelled` flag.** A second run settling while the first download is
2939
+ * still in flight otherwise sets state from the stale one, and the page plays
2940
+ * the previous run's audio under the current run's transcript — a wrong answer
2941
+ * that looks like a right one.
2942
+ *
2943
+ * Two templates had written this hook, identically, doc paragraph included, and
2944
+ * `aai-ui` exported no download helper at all. Both also faked `pending` by
2945
+ * checking `url === undefined && error === undefined`, which reads "idle" and
2946
+ * "downloading" as the same thing — so this reports it.
2947
+ */
2948
+ /** No id: nothing pending, nothing to show. A shared object so `setState` no-ops. */
2949
+ const IDLE = { pending: false };
2950
+ /** Bytes in flight. Shared for the same reason as {@link IDLE}. */
2951
+ const PENDING = { pending: true };
2952
+ /**
2953
+ * Read an upload's bytes and hand back a URL a DOM element can use.
2954
+ *
2955
+ * @param uploadId - The id a completed run reported, or `undefined` before one
2956
+ * exists — which is what a page passes straight through while it waits, and
2957
+ * reports as idle rather than pending.
2958
+ * @param options - See {@link UseDownloadUrlOptions}.
2959
+ * @returns See {@link UseDownloadUrlResult}.
2960
+ *
2961
+ * @example
2962
+ * ```tsx no-check
2963
+ * import { useDownloadUrl, useWorkflowSubmit } from "@alexkroman1/aai-ui";
2964
+ * import type { spokenSummary } from "./agent.ts";
2965
+ *
2966
+ * function Playback() {
2967
+ * const { run } = useWorkflowSubmit<typeof spokenSummary>("spokenSummary");
2968
+ * const output = run?.status === "completed" ? run.output : undefined;
2969
+ * const audio = useDownloadUrl(output?.audio);
2970
+ * if (audio.pending) return <p>Fetching audio…</p>;
2971
+ * if (audio.error !== undefined) return <p role="alert">{audio.error}</p>;
2972
+ * return audio.url === undefined ? null : (
2973
+ * <a href={audio.url} download="summary.mp3">
2974
+ * Download
2975
+ * </a>
2976
+ * );
2977
+ * }
2978
+ * ```
2979
+ *
2980
+ * @public
2981
+ */
2982
+ function useDownloadUrl(uploadId, options = {}) {
2983
+ const [state, setState] = useState(IDLE);
2984
+ const getClient = useWorkflowApiRef(options.api);
2985
+ useEffect(() => {
2986
+ if (uploadId === void 0) {
2987
+ setState(IDLE);
2988
+ return;
2989
+ }
2990
+ let cancelled = false;
2991
+ let objectUrl;
2992
+ setState(PENDING);
2993
+ getClient().download(uploadId).then((blob) => {
2994
+ if (cancelled) return;
2995
+ objectUrl = URL.createObjectURL(blob);
2996
+ setState({
2997
+ url: objectUrl,
2998
+ pending: false
2999
+ });
3000
+ }).catch((err) => {
3001
+ if (!cancelled) setState({
3002
+ error: errorMessage(err),
3003
+ pending: false
3004
+ });
3005
+ });
3006
+ return () => {
3007
+ cancelled = true;
3008
+ if (objectUrl !== void 0) URL.revokeObjectURL(objectUrl);
3009
+ };
3010
+ }, [uploadId, getClient]);
3011
+ return state;
3012
+ }
3013
+ //#endregion
3014
+ //#region src/use-push-to-talk.ts
3015
+ /**
3016
+ * `usePushToTalk` — a hold-to-speak button, for an agent that declares
3017
+ * `turnDetection: "manual"`.
3018
+ *
3019
+ * The three session edges underneath (`session.userTurn`'s `start`, `commit`
3020
+ * and `clear`) are one line each; what a button needs on top of them is
3021
+ * where hand-written versions go wrong, and every one of the four is a turn
3022
+ * that is left OPEN — the microphone live and nothing ever answered:
3023
+ *
3024
+ * - **The pointer leaves.** A `mouseup` outside the button never reaches it.
3025
+ * The handlers here capture the pointer on the way down, so the release
3026
+ * arrives wherever it happens; an `onPointerCancel` (a scroll gesture, a
3027
+ * system dialog) DISCARDS the turn rather than answering half a sentence.
3028
+ * - **The key repeats.** Holding a key fires `keydown` over and over, and each
3029
+ * one would re-open the turn. Only the first counts.
3030
+ * - **The window loses focus mid-hold.** The `keyup` goes to another window
3031
+ * and is never delivered, so the turn is committed on `blur` — what the
3032
+ * caller would have done had the release been seen.
3033
+ * - **The component unmounts mid-hold.** The turn is discarded on the way out.
3034
+ *
3035
+ * And one that is a nuisance rather than a stuck turn: the hold key is ignored
3036
+ * while focus is in a text field, where Space is a character.
3037
+ */
3038
+ const selectRunning = (snapshot) => snapshot.running;
3039
+ /** True when a key event came from somewhere typing belongs. */
3040
+ function isTypingTarget(target) {
3041
+ if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false;
3042
+ return target.isContentEditable || [
3043
+ "INPUT",
3044
+ "TEXTAREA",
3045
+ "SELECT"
3046
+ ].includes(target.tagName);
3047
+ }
3048
+ /**
3049
+ * Hold-to-speak over the session's push-to-talk methods, with the four ways a
3050
+ * turn gets stuck open handled — see this module's doc.
3051
+ *
3052
+ * Must be used inside the provider `mountClient()` installs, against an agent
3053
+ * declaring `turnDetection: "manual"`; any other agent ignores the commands and
3054
+ * its server says so once.
3055
+ *
3056
+ * @example A hold-to-talk button
3057
+ * ```tsx
3058
+ * import { usePushToTalk } from "@alexkroman1/aai-ui";
3059
+ *
3060
+ * function TalkButton() {
3061
+ * const { talking, buttonProps } = usePushToTalk();
3062
+ * return (
3063
+ * <button type="button" {...buttonProps}>
3064
+ * {talking ? "Listening… release to send" : "Hold to talk (or hold Space)"}
3065
+ * </button>
3066
+ * );
3067
+ * }
3068
+ * ```
3069
+ *
3070
+ * @public
3071
+ */
3072
+ function usePushToTalk(options = {}) {
3073
+ const holdKey = options.holdKey ?? "Space";
3074
+ const { userTurn } = useSessionCore();
3075
+ const ready = useSessionSelector(selectRunning);
3076
+ const [talking, setTalking] = useState(false);
3077
+ const held = useRef(false);
3078
+ const press = useCallback(() => {
3079
+ if (held.current || !ready) return;
3080
+ held.current = true;
3081
+ setTalking(true);
3082
+ userTurn.start();
3083
+ }, [ready, userTurn]);
3084
+ const release = useCallback(() => {
3085
+ if (!held.current) return;
3086
+ held.current = false;
3087
+ setTalking(false);
3088
+ userTurn.commit();
3089
+ }, [userTurn]);
3090
+ const cancel = useCallback(() => {
3091
+ if (!held.current) return;
3092
+ held.current = false;
3093
+ setTalking(false);
3094
+ userTurn.clear();
3095
+ }, [userTurn]);
3096
+ useEffect(() => {
3097
+ if (!ready && held.current) {
3098
+ held.current = false;
3099
+ setTalking(false);
3100
+ }
3101
+ }, [ready]);
3102
+ useEffect(() => {
3103
+ if (holdKey === false || typeof window === "undefined") return;
3104
+ const down = (event) => {
3105
+ if (event.code !== holdKey || event.repeat || isTypingTarget(event.target)) return;
3106
+ event.preventDefault();
3107
+ press();
3108
+ };
3109
+ const up = (event) => {
3110
+ if (event.code !== holdKey) return;
3111
+ event.preventDefault();
3112
+ release();
3113
+ };
3114
+ window.addEventListener("keydown", down);
3115
+ window.addEventListener("keyup", up);
3116
+ window.addEventListener("blur", release);
3117
+ return () => {
3118
+ window.removeEventListener("keydown", down);
3119
+ window.removeEventListener("keyup", up);
3120
+ window.removeEventListener("blur", release);
3121
+ };
3122
+ }, [
3123
+ holdKey,
3124
+ press,
3125
+ release
3126
+ ]);
3127
+ const cancelRef = useRef(cancel);
3128
+ cancelRef.current = cancel;
3129
+ useEffect(() => () => cancelRef.current(), []);
3130
+ const buttonProps = useMemo(() => ({
3131
+ onPointerDown: (event) => {
3132
+ event.currentTarget.setPointerCapture?.(event.pointerId);
3133
+ press();
3134
+ },
3135
+ onPointerUp: release,
3136
+ onPointerCancel: cancel,
3137
+ onKeyDown: (event) => {
3138
+ if (event.key !== " " && event.key !== "Enter" || event.repeat) return;
3139
+ event.preventDefault();
3140
+ press();
3141
+ },
3142
+ onKeyUp: (event) => {
3143
+ if (event.key !== " " && event.key !== "Enter") return;
3144
+ event.preventDefault();
3145
+ release();
3146
+ },
3147
+ onContextMenu: (event) => event.preventDefault(),
3148
+ disabled: !ready,
3149
+ "aria-pressed": talking
3150
+ }), [
3151
+ press,
3152
+ release,
3153
+ cancel,
3154
+ ready,
3155
+ talking
3156
+ ]);
3157
+ return useMemo(() => ({
3158
+ talking,
3159
+ ready,
3160
+ press,
3161
+ release,
3162
+ cancel,
3163
+ buttonProps
3164
+ }), [
3165
+ talking,
3166
+ ready,
3167
+ press,
3168
+ release,
3169
+ cancel,
3170
+ buttonProps
3171
+ ]);
3172
+ }
3173
+ //#endregion
2873
3174
  //#region src/use-workflow-runs.ts
2874
3175
  /**
2875
3176
  * The RUNS a workflow has had — the list a page shows beside its form.
@@ -3236,4 +3537,4 @@ function fileAt(input, field) {
3236
3537
  return filesOf(input[field])[0];
3237
3538
  }
3238
3539
  //#endregion
3239
- 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, createBrowserSession, createWorkflowApi, fetchClientConfig, fieldKindFor, isTerminal, mountClient, mountPage, useAgentState, useConversation, useCopy, useDownloadUrl, useEvent, useFlash, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };
3540
+ 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, createBrowserSession, createWorkflowApi, fetchClientConfig, fieldKindFor, isTerminal, mountClient, mountPage, useAgentState, useConversation, useCopy, useDownloadUrl, useEvent, useFlash, usePushToTalk, useRunKey, useSession, useSessionActions, useSessionControls, useSessionError, useSessionSelector, useSessionStatus, useTheme, useToolCallStart, useToolResult, useUserTranscript, useWorkflowProgress, useWorkflowRun, useWorkflowRuns, useWorkflowStream, useWorkflowSubmit, useWorkflows };