@alexkroman1/aai-ui 13.3.0 → 14.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +159 -69
  2. package/dist/{_colors-CZ6OlPbL.js → _colors-CpZO-88A.js} +24 -1
  3. package/dist/_recover-run.d.ts +2 -2
  4. package/dist/_submission-state.d.ts +92 -0
  5. package/dist/_upload-files.d.ts +2 -2
  6. package/dist/_upload-report.d.ts +26 -0
  7. package/dist/{_utils-CyzjK0gW.js → _utils-DnQDM9Uy.js} +3 -3
  8. package/dist/_utils.d.ts +3 -3
  9. package/dist/_web-storage.d.ts +43 -0
  10. package/dist/_workflow-files.d.ts +1 -1
  11. package/dist/agent-state-labels.d.ts +60 -0
  12. package/dist/audio.js +20 -14
  13. package/dist/{chat-view-Bv5VFJIE.js → chat-view-C_3T7Ln8.js} +96 -29
  14. package/dist/{client-config-DD820zHn.js → client-config-DJQHnYjm.js} +4 -4
  15. package/dist/client-config.d.ts +4 -4
  16. package/dist/client-dir.d.ts +1 -1
  17. package/dist/client-dir.js +1 -1
  18. package/dist/components/_colors.d.ts +23 -0
  19. package/dist/components/_form-readiness.d.ts +1 -1
  20. package/dist/components/bullet-list.d.ts +74 -0
  21. package/dist/components/button.js +3 -3
  22. package/dist/components/chat-view.js +1 -1
  23. package/dist/components/console-shell.d.ts +16 -20
  24. package/dist/components/controls.js +3 -3
  25. package/dist/components/facts.d.ts +81 -0
  26. package/dist/components/form-fields.d.ts +6 -6
  27. package/dist/components/form-types.d.ts +1 -1
  28. package/dist/components/form.d.ts +1 -1
  29. package/dist/components/message-list.js +1 -1
  30. package/dist/components/session-error-banner.d.ts +69 -0
  31. package/dist/components/start-screen.js +1 -1
  32. package/dist/components/tool-call-block.js +1 -1
  33. package/dist/components/tool-config-context.d.ts +1 -1
  34. package/dist/components/workflow-progress.d.ts +11 -4
  35. package/dist/context.d.ts +142 -19
  36. package/dist/context.js +155 -17
  37. package/dist/default-client/assets/{audio-BuDICbPf.js → audio-9zQsNc1w.js} +1 -1
  38. package/dist/default-client/assets/index-BTv30Z4F.css +2 -0
  39. package/dist/default-client/assets/index-RAZ-29Sz.js +284 -0
  40. package/dist/default-client/index.html +2 -2
  41. package/dist/define-client.d.ts +19 -19
  42. package/dist/define-client.js +19 -19
  43. package/dist/hooks.d.ts +44 -8
  44. package/dist/hooks.js +19 -13
  45. package/dist/index.d.ts +11 -7
  46. package/dist/index.js +418 -185
  47. package/dist/internal.d.ts +2 -2
  48. package/dist/internal.js +5 -5
  49. package/dist/{message-list-C0pL7x41.js → message-list-CdOnSh5m.js} +19 -12
  50. package/dist/page.d.ts +11 -11
  51. package/dist/session-core-audio-setup.d.ts +1 -1
  52. package/dist/session-core-dial.d.ts +0 -2
  53. package/dist/{session-core-C9elBIdu.js → session-core-gwePM95B.js} +125 -52
  54. package/dist/session-core-messages.d.ts +2 -2
  55. package/dist/session-core-types.d.ts +58 -1
  56. package/dist/session-core.d.ts +6 -6
  57. package/dist/session-core.js +2 -2
  58. package/dist/session-resume-store.d.ts +3 -3
  59. package/dist/{tool-call-block-Bunc6rCw.js → tool-call-block-C2t_5fpp.js} +27 -9
  60. package/dist/{tool-config-context-Bh8p3DtG.js → tool-config-context-Es4YUzV2.js} +1 -1
  61. package/dist/types.d.ts +19 -4
  62. package/dist/types.js +2 -2
  63. package/dist/{url-chips-C2u7QPv8.js → url-chips-BxhzZgk2.js} +4 -4
  64. package/dist/use-conversation.d.ts +1 -1
  65. package/dist/{use-user-transcript-DFTSEuZN.js → use-user-transcript-uyHhzy4d.js} +3 -2
  66. package/dist/use-workflow-form.d.ts +34 -2
  67. package/dist/{use-workflow-run-CXGEcM0l.js → use-workflow-run-CP2ekKPV.js} +3 -6
  68. package/dist/use-workflow-stream.d.ts +1 -1
  69. package/dist/workflow-client.d.ts +1 -1
  70. package/package.json +2 -2
  71. package/styles.css +78 -0
  72. package/dist/default-client/assets/index-B1_ROnTJ.js +0 -284
  73. package/dist/default-client/assets/index-S5fkKi6B.css +0 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @alexkroman1/aai-ui
2
2
 
3
- The browser client for aai agents: React 19 components, hooks, and a
3
+ The browser client for aai agents: React 19 hooks and components over a
4
4
  framework-agnostic session core (WebSocket + microphone + playback).
5
5
 
6
6
  ```sh
@@ -11,64 +11,156 @@ Every agent gets this UI for free — `aai dev` and deployed agents serve a
11
11
  default client built from this package. Install it directly when the agent
12
12
  has its own `client.tsx`.
13
13
 
14
- **Two front doors, two mounts.** A voice agent's page calls `client()` and
14
+ ## UI integration
15
+
16
+ The hooks here are what a page reads an agent with: the live call, the state
17
+ the agent projects, the tool calls as they run, and the durable runs it
18
+ started. **There is no route to write.** The agent server already serves the
19
+ session socket and the workflow HTTP API, so a component talks to a live
20
+ agent with no glue file in between — a client is one `client.tsx` calling one
21
+ mount.
22
+
23
+ **Two front doors, two mounts.** A voice agent's page calls `mountClient()` and
15
24
  talks to a live session. A workflow app's page (`workflowApp()`, or
16
- `agent({ page: "static" })`) calls `page()` and talks to the workflow HTTP
25
+ `agent({ page: "static" })`) calls `mountPage()` and talks to the workflow HTTP
17
26
  API — no session, no socket, no microphone. Both are still `client.tsx`,
18
27
  still React, still the same theme tokens.
19
28
 
20
- ## A custom voice client
29
+ ### Agent `agent.ts`
30
+
31
+ The session's state lives in a slot, and `syncState` is what the browser sees
32
+ — pushed after every tool call, so the client never rebuilds it from events:
33
+
34
+ ```ts
35
+ import { agent, sessionSlot } from "@alexkroman1/aai";
36
+
37
+ const desk = sessionSlot("desk", () => ({ symbols: [] as string[] }));
38
+
39
+ export default agent({
40
+ name: "Market Desk",
41
+ systemPrompt: "You look up quotes and keep the caller's watchlist.",
42
+ syncState: desk.projection((state) => ({ symbols: state.symbols })),
43
+ });
44
+ ```
45
+
46
+ ### Tool `tools/get_quote.ts`
47
+
48
+ A tool is a **file**, named by its own filename. Nothing registers it, and
49
+ `agent()` takes no `tools` field:
50
+
51
+ ```ts
52
+ import { tool } from "@alexkroman1/aai";
53
+ import { z } from "zod";
54
+
55
+ export default tool({
56
+ description: "Look up the latest price for one ticker symbol",
57
+ inputSchema: z.object({ symbol: z.string() }),
58
+ execute: async ({ symbol }, ctx) => {
59
+ const res = await fetch(`https://api.example.com/quote/${symbol}`, {
60
+ headers: { authorization: `Bearer ${ctx.env.QUOTES_API_KEY}` },
61
+ signal: ctx.signal,
62
+ });
63
+ return (await res.json()) as { symbol: string; price: number };
64
+ },
65
+ });
66
+ ```
21
67
 
22
- `client()` mounts the default chat shell with your sidebar, or replaces the
23
- whole UI with a custom component:
68
+ ### UI component for a tool `quote-card.tsx`
69
+
70
+ `useToolResult` fires once per settled call of one named tool, with the
71
+ result already parsed. A component that mounts late still receives the
72
+ results of calls that finished before it — a result is a value the UI is
73
+ driven from, not a moment:
24
74
 
25
75
  ```tsx
26
- import "@alexkroman1/aai-ui/styles.css";
27
- import { client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
76
+ import { useState } from "react";
77
+ import { useToolResult } from "@alexkroman1/aai-ui";
28
78
 
29
- type OrderView = { items: string[]; total: string };
79
+ type Quote = { symbol: string; price: number };
30
80
 
31
- function OrderSidebar() {
32
- const theme = useTheme();
33
- // Server state projected by the agent's `syncState`, pushed after every
34
- // tool call.
35
- const order = useAgentState<OrderView>() ?? { items: [], total: "$0.00" };
81
+ export default function QuoteCard() {
82
+ const [quote, setQuote] = useState<Quote>();
83
+ useToolResult<Quote>("get_quote", (result) => setQuote(result));
84
+
85
+ if (!quote) return null;
36
86
  return (
37
- <div style={{ color: theme.text }}>
38
- {order.items.map((item) => (
39
- <div key={item}>{item}</div>
40
- ))}
41
- <strong style={{ color: theme.primary }}>{order.total}</strong>
87
+ <p>
88
+ {quote.symbol} — {quote.price.toFixed(2)}
89
+ </p>
90
+ );
91
+ }
92
+ ```
93
+
94
+ `useToolCallStart` is the same hook for the other end of the call — the
95
+ pending invocation and its arguments, for a spinner rather than a value.
96
+ Either one takes the tool's own shape as a type argument, derived from the
97
+ tool module by a **type-only** import that is erased and so pulls no server
98
+ code into the bundle: `useToolResult<InferToolOutput<typeof getQuote>>(…)`
99
+ for the result, `useToolCallStart<InferToolInput<typeof getQuote>>(…)` for
100
+ the arguments.
101
+
102
+ ### Client `client.tsx`
103
+
104
+ `mountClient()` mounts the default chat shell — start screen, transcript,
105
+ controls — with your components in it. `sidebar` takes the COMPONENT, not an
106
+ element:
107
+
108
+ ```tsx no-check
109
+ import "@alexkroman1/aai-ui/styles.css";
110
+ import { mountClient, useAgentState } from "@alexkroman1/aai-ui";
111
+ import QuoteCard from "./quote-card.tsx";
112
+
113
+ function Watchlist() {
114
+ // Whatever `syncState` projected, live. Null until the first push, which is
115
+ // a moment the UI has to render.
116
+ const desk = useAgentState<{ symbols: string[] }>();
117
+ return (
118
+ <div>
119
+ <QuoteCard />
120
+ {desk?.symbols.map((symbol) => <div key={symbol}>{symbol}</div>)}
42
121
  </div>
43
122
  );
44
123
  }
45
124
 
46
- // `sidebar` takes the COMPONENT, not an element — the shell renders it.
47
- client({ sidebar: OrderSidebar });
125
+ mountClient({
126
+ name: "Market Desk",
127
+ sidebar: Watchlist,
128
+ // Icon and label per tool, for the transcript's tool rows.
129
+ tools: { get_quote: { icon: "📈", label: "Fetching quote" } },
130
+ theme: { primary: "#2f9e44" },
131
+ });
48
132
  ```
49
133
 
134
+ The type argument above restates a shape the agent already knows. Move the
135
+ slot and its projection into a `shared.ts` both ends import, pass the
136
+ projection itself — `useAgentState(deskProjection)` — and the state is typed
137
+ from the projection and the pre-first-push frame derived from it, so there is
138
+ nothing to restate and no branch for the first render.
139
+
140
+ Pass `component` instead of `sidebar` to replace the whole shell. It renders
141
+ inside the same providers, so every hook here works in it unchanged.
142
+
50
143
  ## A workflow app
51
144
 
52
- `page()` mounts a form over the agent's workflows. `<WorkflowFields>` renders
53
- one control per scalar property of the workflow's own input schema, so adding
54
- a field to the schema adds it to the page:
145
+ `mountPage()` mounts a form over the agent's workflows and installs no session.
146
+ `<WorkflowFields>` renders one control per scalar property of the workflow's
147
+ own input schema, so adding a field to the schema adds it to the page:
55
148
 
56
- ```tsx no-check
149
+ ```tsx
57
150
  import "@alexkroman1/aai-ui/styles.css";
58
151
  import {
59
152
  Form,
60
- page,
153
+ mountPage,
61
154
  SubmitButton,
62
155
  UploadProgressBar,
63
156
  useWorkflowSubmit,
64
157
  WorkflowFields,
65
158
  WorkflowProgress,
66
159
  } from "@alexkroman1/aai-ui";
67
- // Type-only, so it is ERASED — the agent module is not in this bundle.
68
- import type { digest } from "./agent.ts";
69
160
 
70
161
  function App() {
71
- const { submitForm, run, pending, upload, error } = useWorkflowSubmit<typeof digest>("digest");
162
+ const { submitForm, run, pending, upload, error } =
163
+ useWorkflowSubmit("digest");
72
164
  return (
73
165
  <main className="mx-auto flex max-w-2xl flex-col gap-6 p-8">
74
166
  <Form onSubmit={submitForm} error={error}>
@@ -78,45 +170,56 @@ function App() {
78
170
  {/* The upload is its own wait: the run does not exist until the bytes
79
171
  are in, so nothing else on the page can describe it. */}
80
172
  <UploadProgressBar upload={upload} />
81
- {/* What the run has SAID, from `report()` in its steps. */}
173
+ {/* What the run has SAID, from `stepReport()` in its steps. */}
82
174
  <WorkflowProgress runId={run?.runId} />
83
175
  {run?.status === "completed" && <pre>{JSON.stringify(run.output)}</pre>}
84
176
  </main>
85
177
  );
86
178
  }
87
179
 
88
- page({ name: "Digest", component: App });
180
+ mountPage({ name: "Digest", component: App });
89
181
  ```
90
182
 
91
- `WorkflowOutputOf<typeof myWorkflow>` is what makes
92
- `run.status === "completed"` narrow to a typed `run.output` a type-only
93
- import of `agent.ts` is erased, so it pulls no server code into the bundle.
183
+ Naming the workflow's def — `useWorkflowSubmit<typeof digest>("digest")`, off
184
+ a **type-only** import of `agent.ts` is what makes `submit()` take the
185
+ declared input and `run.status === "completed"` narrow to a typed
186
+ `run.output`. The import is erased, so it pulls no server code into the
187
+ bundle.
94
188
 
95
189
  ## What is in the package
96
190
 
97
- The reference below groups by TypeScript kind. This is the same surface
98
- grouped by what it is for.
99
-
100
- **Mounts** — `client()`, `page()`, the config each takes and the handle each
101
- returns (`ClientConfig`, `ClientHandle`, `PageConfig`, `PageHandle`).
102
- `fetchClientConfig()` reads the agent's declared `name`/`greeting` on a page,
103
- which `client()` does for itself.
104
-
105
- **The live call** — `useSession()` for the whole snapshot,
106
- `useSessionSelector()` for one slice, `useUserTranscript()` for the caller's
107
- in-progress turn. `createSessionCore()` is the same session as a plain store,
108
- for a non-React client. `SessionSnapshot`, `AgentState`, `ChatMessage`,
191
+ The [API reference](https://alexkroman.github.io/agent/) groups by TypeScript
192
+ kind. This is the same surface grouped by what it is for.
193
+
194
+ **Mounts** — `mountClient()`, `mountPage()`, the config each takes and the
195
+ handle each returns (`ClientConfig`, `ClientHandle`, `PageConfig`,
196
+ `PageHandle`). `fetchClientConfig()` reads the agent's declared
197
+ `name`/`greeting` on a page, which `mountClient()` does for itself.
198
+
199
+ **The live call** — `useSession()` for the whole snapshot plus the controls
200
+ (`start`, `toggle`, `cancel`, `reset`, `resetState`, `restart`, `disconnect`,
201
+ `end`); `useSessionSelector()` for one slice, with `useSessionStatus()`,
202
+ `useSessionError()` and `useSessionActions()` as the narrow reads a custom
203
+ chrome repeats; `useUserTranscript()` for the caller's in-progress turn, which
204
+ keeps `null` (silent) and `""` (speech, no words yet) apart; `useConversation()`
205
+ for the interleaved transcript with nothing rendered. `createBrowserSession()` is
206
+ the same session as a plain store with an immutable snapshot per change, for a
207
+ non-React client. `SessionSnapshot`, `AgentState`, `ChatMessage`,
109
208
  `ToolCallInfo`, `SessionError` and `SessionErrorCode` come with it.
110
209
 
111
210
  **What the agent pushes** — `useAgentState()` (the `syncState` projection),
112
211
  `useToolResult()` / `useToolCallStart()` (tool calls as they run), and
113
- `useEvent()` (custom events from `ctx.send`).
212
+ `useEvent()` (custom events from `ctx.send`, and the one to prefer in new
213
+ code over reading tool results).
114
214
 
115
- **Chat chrome** — `ChatView` is the whole surface; `MessageList`, `Controls`,
116
- `Markdown`, `ToolCallRow`, `StartScreen`, `SidebarLayout`, `AutoScroll` and
215
+ **Chat chrome** — `ChatView` is the whole surface; `ConsoleShell`,
216
+ `MessageList`, `Controls`, `Markdown`, `ToolCallRow`, `SessionErrorBanner`,
217
+ `StartScreen`, `SidebarLayout`, `AutoScroll`, `BulletList`, `Facts` and
117
218
  `Button` are its parts, exported so a custom chrome reuses them rather than
118
219
  approximating them. `AutoScroll` is the one to reach for directly: it is the
119
220
  only scroll-pinning implementation here, and it needs a **bounded height**.
221
+ `AGENT_STATE_LABELS` and `WORKFLOW_STATUS_LABELS` are the default state words,
222
+ so a chrome overrides the one term it has a better word for.
120
223
 
121
224
  **Forms** — `Form` collects typed values off the DOM on submit. `Field`,
122
225
  `TextField`, `NumberField`, `TextAreaField`, `SelectField`, `CheckboxField`,
@@ -127,9 +230,11 @@ them from a schema. A plain `<input name="x">` works identically.
127
230
  `useWorkflowSubmit()` / `useWorkflowStream()` (start one and watch it),
128
231
  `useWorkflowRun()` (watch one by id), `useWorkflowRuns()` (the history),
129
232
  `useWorkflowProgress()` (what a run has written), `useWorkflows()` (the
130
- listing). `<WorkflowProgress>` and `<UploadProgressBar>` are the rendered
131
- halves. `WorkflowRun`, `WorkflowSummary`, `WorkflowOutputOf` and
132
- `isTerminal()` are the vocabulary.
233
+ listing), `useDownloadUrl()` (an upload a run produced, as a URL a DOM element
234
+ accepts), `useRunKey()` (the storage-backed key a submission is recovered by).
235
+ `<WorkflowProgress>` and `<UploadProgressBar>` are the
236
+ rendered halves. `WorkflowRun`, `WorkflowSummary`, `WorkflowInputOf`,
237
+ `WorkflowOutputOf` and `isTerminal()` are the vocabulary.
133
238
 
134
239
  **Theme** — `useTheme()` and `ClientTheme`.
135
240
 
@@ -140,23 +245,8 @@ of them something a `client.tsx` reaches for:
140
245
 
141
246
  | Subpath | Reach for it when |
142
247
  | --- | --- |
143
- | `/client-dir` | serving the prebuilt default client from Node — `defaultClientDir()`, the filesystem path `createServer({ clientDir })` wants |
144
- | `/internal` | never, from application code: the plumbing `client()` installs for itself (the session and theme providers, the default shell's URL chips, the tool-config context, the pre-connection lookup). Not a public API and not covered by semver |
145
-
146
- ## Hooks
147
-
148
- Inside components rendered by `client()`:
149
-
150
- - `useSession()` — connection state, transcript, and the call controls:
151
- `start`, `toggle`, `cancel`, `reset`, `resetState`, `disconnect`, `end`.
152
- - `useAgentState<T>()` — the agent's `syncState` projection, live.
153
- - `useToolResult(name, cb)` / `useToolCallStart(name, cb)` — observe tool
154
- calls as they run (e.g. to render a card per result).
155
- - `useEvent(name, cb)` — custom events the agent pushes with `ctx.send`.
156
- - `useTheme()` — the resolved theme colors for custom components.
157
-
158
- For a non-React integration, `createSessionCore()` exposes the same session
159
- as a plain store with an immutable snapshot per change.
248
+ | `/client-dir` | serving the prebuilt default client from Node — `defaultClientDir()`, the filesystem path `createRuntimeServer({ clientDir })` wants |
249
+ | `/internal` | never, from application code: the plumbing `mountClient()` installs for itself (the session and theme providers, the default shell's URL chips, the tool-config context, the pre-connection lookup). Not a public API and not covered by semver |
160
250
 
161
251
  ## Documentation
162
252
 
@@ -51,5 +51,28 @@ function primaryTint(primary, surface, pct) {
51
51
  function mixInto(color, ground, pct) {
52
52
  return `color-mix(in srgb, ${color} ${pct}%, ${ground})`;
53
53
  }
54
+ /**
55
+ * The focus ring, as the class half of a pair.
56
+ *
57
+ * The ring is the only thing a keyboard user gets, and it has regressed to
58
+ * invisible twice — `outline-none` shipped here with no replacement, on the
59
+ * button and then on the URL chips, which is WCAG 2.4.7 on every control in the
60
+ * default client. Written as a SHORTHAND so it cannot be undone by Tailwind's
61
+ * shared `--tw-outline-style` variable.
62
+ *
63
+ * It is inert on its own: an outline with no colour draws nothing, so a control
64
+ * using this must also spread {@link focusRingStyle}. Naming the two together
65
+ * is what stops the next control shipping one without the other.
66
+ */
67
+ const FOCUS_RING = "outline-none focus-visible:[outline:2px_solid] focus-visible:[outline-offset:2px]";
68
+ /**
69
+ * The style half of {@link FOCUS_RING} — the colour the outline draws in.
70
+ *
71
+ * A real CSS property rather than a variant class, so the `focus-visible` rule
72
+ * only has to turn the outline on.
73
+ */
74
+ function focusRingStyle(primary) {
75
+ return { outlineColor: primary };
76
+ }
54
77
  //#endregion
55
- export { primaryTint as i, THINKING_COLOR as n, inkTint as r, ERROR_COLOR as t };
78
+ export { inkTint as a, focusRingStyle as i, FOCUS_RING as n, primaryTint as o, THINKING_COLOR as r, ERROR_COLOR as t };
@@ -55,8 +55,8 @@ import type { WorkflowApi } from "./workflow-client.ts";
55
55
  export type RecoverRunOptions = {
56
56
  /** The workflow whose runs are indexed under `key`. */
57
57
  workflow: string;
58
- /** The caller's handle on its own run, or undefined for a page with none. */
59
- key: string | undefined;
58
+ /** The caller's handle on its own run. `useDefaultRunKey` always supplies one. */
59
+ key: string;
60
60
  /** Whether the caller asked for this at all — `recover` at the call site. */
61
61
  enabled: boolean;
62
62
  /** The stable getter from `useWorkflowApiRef`. */
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The state a form submission carries, for both hooks that make one.
3
+ *
4
+ * `useWorkflowSubmit` and `useWorkflowStream` stay separate hooks — they differ
5
+ * in WHEN the run is created relative to the bytes, which is the whole reason
6
+ * the streaming one exists — but everything around that ordering was written
7
+ * twice: the same four `useState`s, the same ref holding the live submission,
8
+ * the same prologue, the same superseded-submission guard, the same `reset`,
9
+ * and the same pause pair. Roughly forty-five lines and thirty of comment.
10
+ *
11
+ * The copy had already diverged in ways nobody chose, which is the argument for
12
+ * this module rather than for tidiness. What is here is only the scaffold; each
13
+ * hook still owns its own `submit` body.
14
+ *
15
+ * ## The superseded-submission rule
16
+ *
17
+ * A submission that has been replaced owns none of this state any more. Its
18
+ * walk unwinds AFTER the next one has already set `starting`, so clearing
19
+ * unconditionally in a `finally` would report the live submission as finished
20
+ * and drop the bar it is drawing. {@link SubmissionState.end} is that check,
21
+ * made once here instead of at each hook's own `finally`.
22
+ */
23
+ import type { Dispatch, SetStateAction } from "react";
24
+ import type { UploadGate } from "./_upload-session.ts";
25
+ import type { UploadStatus } from "./use-workflow-form.ts";
26
+ /**
27
+ * What a hook installs as its live submission.
28
+ *
29
+ * Generic over the token because the two hooks hold different things —
30
+ * `useWorkflowSubmit` an `UploadSession` (the walk needs its id maps),
31
+ * `useWorkflowStream` a bare gate in a wrapper — and all this module needs from
32
+ * either is the gate, which is what a supersede and a `reset` cancel.
33
+ */
34
+ export type SubmissionToken = {
35
+ gate: UploadGate;
36
+ };
37
+ /**
38
+ * The stable half: everything a `submit` body calls.
39
+ *
40
+ * Split from the values deliberately. A `submit` is a `useCallback` and this is
41
+ * one of its dependencies, so a bag that changed identity whenever `upload`
42
+ * changed would rebuild `submit` — and with it the `onSubmit` handed to
43
+ * `<Form>` — on every progress report. Every member here is a `useState`
44
+ * setter or a `useCallback` with no changing dependency, so the bag is stable
45
+ * for the component's life.
46
+ */
47
+ export type SubmissionActions<S extends SubmissionToken> = {
48
+ /**
49
+ * The full dispatch, not a plain setter: `useWorkflowSubmit`'s recovery
50
+ * lookup writes `(current) => current ?? found`, so that a run the person has
51
+ * already started this session is never displaced by one read back from the
52
+ * key index.
53
+ */
54
+ setRunId: Dispatch<SetStateAction<string | undefined>>;
55
+ setStartError: Dispatch<SetStateAction<string | undefined>>;
56
+ setUpload: Dispatch<SetStateAction<UploadStatus | undefined>>;
57
+ /**
58
+ * Open a submission: supersede whatever was in flight and install `token`.
59
+ *
60
+ * Cancels the previous gate first, so a stale submission cannot park the new
61
+ * one, and clears the previous run id BEFORE the request rather than when it
62
+ * returns — a finished result sitting under a form that is already submitting
63
+ * again is the one wrong answer this can give, and it looks like a right one.
64
+ */
65
+ begin: (token: S) => void;
66
+ /**
67
+ * Close `token`, if it is still the live one. See "The superseded-submission
68
+ * rule" above.
69
+ */
70
+ end: (token: S) => void;
71
+ /** Put the form back: abandon the bytes and drop the result. */
72
+ reset: () => void;
73
+ /** Park the bytes and say so on the bar. */
74
+ pauseUpload: () => void;
75
+ /** Send the rest. */
76
+ resumeUpload: () => void;
77
+ };
78
+ /** What {@link useSubmissionState} hands back. */
79
+ export type SubmissionState<S extends SubmissionToken> = {
80
+ /** The run this submission started, once it has one. */
81
+ runId: string | undefined;
82
+ /** Whether a submission is in flight — the POST and the bytes, not the run. */
83
+ starting: boolean;
84
+ /** The submission's own failure, as against the run's. */
85
+ startError: string | undefined;
86
+ /** The bar's state, or nothing when there is no upload to describe. */
87
+ upload: UploadStatus | undefined;
88
+ /** See {@link SubmissionActions} for why these are their own bag. */
89
+ actions: SubmissionActions<S>;
90
+ };
91
+ /** The shared submission scaffold. See the module doc. */
92
+ export declare function useSubmissionState<S extends SubmissionToken>(): SubmissionState<S>;
@@ -10,7 +10,7 @@
10
10
  * one file rather than walking an input and shares only the gate underneath both
11
11
  * (`_upload-session.ts`).
12
12
  */
13
- import type { UploadParallel } from "@alexkroman1/aai/workflow-api";
13
+ import type { UploadParallelOption } from "@alexkroman1/aai/workflow-api";
14
14
  import { type UploadGate } from "./_upload-session.ts";
15
15
  import type { UploadStatus } from "./use-workflow-form.ts";
16
16
  import type { WorkflowApi } from "./workflow-client.ts";
@@ -80,4 +80,4 @@ export declare function createUploadSession(workflow: string): UploadSession;
80
80
  * last byte lands, so the incomplete record a streamed upload leaves along the
81
81
  * way is one nobody reads.
82
82
  */
83
- export declare function uploadFiles(api: WorkflowApi, input: unknown, report: (status: UploadStatus) => void, parallel: UploadParallel | undefined, session: UploadSession): Promise<unknown>;
83
+ export declare function uploadFiles(api: WorkflowApi, input: unknown, report: (status: UploadStatus) => void, parallel: UploadParallelOption | undefined, session: UploadSession): Promise<unknown>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Rate-limiting the upload progress report to what a bar can actually show.
3
+ *
4
+ * `onProgress` fires on every `XMLHttpRequest` progress event, and the parts
5
+ * uploader runs `UPLOAD_PART_CONCURRENCY` of those at once — so the raw rate is
6
+ * on the order of a hundred reports a second, sustained for the whole upload,
7
+ * which for the multi-hundred-megabyte files this feature exists for is
8
+ * minutes. Each one is a `setState`, and each re-renders the enclosing `<Form>`
9
+ * subtree: every field `<WorkflowFields>` rendered, the bar, and
10
+ * `<WorkflowProgress>` beside it. Progress events arrive as separate
11
+ * macrotasks, so React's automatic batching does not merge them.
12
+ *
13
+ * The bar has at most ~100 visually distinct states. Dropping a report that
14
+ * would render identically is therefore free, and it is the whole mechanism —
15
+ * no timer, so the LAST report of a burst is never left unrendered waiting for
16
+ * a window to close.
17
+ */
18
+ import type { UploadStatus } from "./use-workflow-form.ts";
19
+ /**
20
+ * Wrap an `UploadStatus` setter so redundant reports never reach React.
21
+ *
22
+ * Clearing the status (`undefined`) always passes and resets the comparison —
23
+ * it ends one file's bar, and the next file must not be coalesced against the
24
+ * previous one's last frame.
25
+ */
26
+ export declare function coalesceUploadReports(set: (status: UploadStatus | undefined) => void): (status: UploadStatus | undefined) => void;
@@ -11,7 +11,7 @@ function truncate(s, max = 80) {
11
11
  }
12
12
  /**
13
13
  * The page this UI is served from — the agent's base URL when none is
14
- * configured explicitly. One definition so `client()`'s default and the
14
+ * configured explicitly. One definition so `mountClient()`'s default and the
15
15
  * shareable-URL chip can never disagree about which agent the page is.
16
16
  */
17
17
  function pageBaseUrl() {
@@ -22,8 +22,8 @@ function pageBaseUrl() {
22
22
  * Set the document title, when there is one to set and a document to set it on.
23
23
  *
24
24
  * Both mounts do this and neither may clobber a title the page's own HTML
25
- * declared — `client()`'s custom-component tier because there is no shell header
26
- * to show the name in, `page()` because there is no shell at all. One copy, for
25
+ * declared — `mountClient()`'s custom-component tier because there is no shell header
26
+ * to show the name in, `mountPage()` because there is no shell at all. One copy, for
27
27
  * the reason `resolveContainer` and `mountRoot` are shared: the rule is the
28
28
  * same, so the two mounts must not be able to disagree about it.
29
29
  */
package/dist/_utils.d.ts CHANGED
@@ -4,7 +4,7 @@ export declare function tryParseJSON(str: string | undefined): unknown;
4
4
  export declare function truncate(s: string, max?: number): string;
5
5
  /**
6
6
  * The page this UI is served from — the agent's base URL when none is
7
- * configured explicitly. One definition so `client()`'s default and the
7
+ * configured explicitly. One definition so `mountClient()`'s default and the
8
8
  * shareable-URL chip can never disagree about which agent the page is.
9
9
  */
10
10
  export declare function pageBaseUrl(): string;
@@ -12,8 +12,8 @@ export declare function pageBaseUrl(): string;
12
12
  * Set the document title, when there is one to set and a document to set it on.
13
13
  *
14
14
  * Both mounts do this and neither may clobber a title the page's own HTML
15
- * declared — `client()`'s custom-component tier because there is no shell header
16
- * to show the name in, `page()` because there is no shell at all. One copy, for
15
+ * declared — `mountClient()`'s custom-component tier because there is no shell header
16
+ * to show the name in, `mountPage()` because there is no shell at all. One copy, for
17
17
  * the reason `resolveContainer` and `mountRoot` are shared: the rule is the
18
18
  * same, so the two mounts must not be able to disagree about it.
19
19
  */
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Guarded access to the browser's key-value stores.
3
+ *
4
+ * Three modules in this package remember something across a load — the session
5
+ * id (`session-resume-store.ts`), the upload ids a form has minted
6
+ * (`_upload-recall.ts`) and a page's run correlation key (`use-run-key.ts`) —
7
+ * and each had written out the same two rules by hand, eight `try`/`catch`
8
+ * blocks and two URL-derived key builders between them. The tell was that each
9
+ * module's doc cited the others as precedent, which is a convention held by
10
+ * authors reading each other's comments rather than by code.
11
+ *
12
+ * Both rules are here once:
13
+ *
14
+ * - **Every access is guarded.** Storage THROWS outright in some contexts
15
+ * (Safari private mode, an iframe blocked by policy) and is ABSENT in others
16
+ * (any server-side render) — and reaching for the PROPERTY is itself what
17
+ * throws, before a method is called, which is why the guard has to wrap the
18
+ * lookup and not just the call. A caller that cannot remember must degrade to
19
+ * the behaviour it would have had anyway, never fail to render.
20
+ * - **A slot is namespaced by the page's own URL**, so two agents served from
21
+ * one origin — every deployed agent, at `/:slug/` — cannot inherit each
22
+ * other's state.
23
+ */
24
+ /** Which of the two stores; they differ only in how long an entry outlives the tab. */
25
+ export type StorageKind = "session" | "local";
26
+ /** The stored value, or undefined — for a missing entry and an absent store alike. */
27
+ export declare function storageGet(kind: StorageKind, key: string): string | undefined;
28
+ /** Remember a value. A store that refuses is a no-op, never a throw. */
29
+ export declare function storageSet(kind: StorageKind, key: string, value: string): void;
30
+ /** Forget a value. Nothing stored and no store are the same outcome. */
31
+ export declare function storageRemove(kind: StorageKind, key: string): void;
32
+ /**
33
+ * A storage key namespaced by a URL.
34
+ *
35
+ * `target` is resolved against the document, so a relative path ("./", the
36
+ * default-client case) and the absolute form of the same agent agree on one
37
+ * key.
38
+ *
39
+ * @param prefix - The owning module's namespace, e.g. `"aai:session:"`.
40
+ * @param target - What to resolve — an agent's `platformUrl`, or `"./"` for the
41
+ * page's own directory.
42
+ */
43
+ export declare function urlSlot(prefix: string, target: string): string;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Its own module because both submit hooks need the identical answer and then do
5
5
  * two different things with it — `useWorkflowSubmit` stores each file and passes
6
- * its id, `useWorkflowStream` cuts it into parts and passes the group they share.
6
+ * its id, `useWorkflowStream` mints the id first so the run can start on it.
7
7
  * A second copy of this predicate would be a form field that one hook treats as a
8
8
  * file and the other does not, which is invisible until the run reads the wrong
9
9
  * kind of string.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * One word for where the CALL has got to, for each {@link AgentState} there is.
3
+ *
4
+ * The sibling of `WORKFLOW_STATUS_LABELS` next door, for the same reason and
5
+ * with the same shape. Three custom chromes carried three renderings of this
6
+ * one seven-member union, all written as a chain of ternaries over the raw
7
+ * member: one mapped `speaking` to "Narrating" and fell through to "Idle" for
8
+ * everything it had not listed, one shouted (`thinking` → "PROCESSING"), and
9
+ * the third rendered the enum member itself — so a caller of that agent read a
10
+ * lowercase `disconnected` in the header of a page nobody had decided to say
11
+ * that on.
12
+ *
13
+ * The exhaustiveness argument is the one that makes this worth exporting rather
14
+ * than documenting. A `Record` over {@link AgentState} means a state ADDED to
15
+ * the union is a compile error HERE, in one place every page inherits, instead
16
+ * of falling through each page's own `: "Idle"` tail into whichever word that
17
+ * page happened to end on — which is a silent wrong label, not a missing one. A
18
+ * page overriding one member keeps that: spreading a complete record cannot
19
+ * drop a key.
20
+ *
21
+ * There is deliberately no `StatusDot` component beside it. The dot markup and
22
+ * its palette are what each chrome exists to look like — a CRT's glow, a
23
+ * dispatch board's alert colours — and a component would take the one part
24
+ * that is genuinely shared (the words) hostage to the part that is not.
25
+ */
26
+ import type { AgentState } from "./types.ts";
27
+ /**
28
+ * The default label per {@link AgentState}.
29
+ *
30
+ * Override the ones your page has a better word for and keep the rest:
31
+ *
32
+ * ```ts
33
+ * import type { AgentState } from "@alexkroman1/aai-ui";
34
+ * import { AGENT_STATE_LABELS } from "@alexkroman1/aai-ui";
35
+ *
36
+ * // A dispatch board that shouts, and renames one state.
37
+ * const STATE_LABEL = { ...AGENT_STATE_LABELS, thinking: "Processing" };
38
+ * const shout = (s: AgentState) => STATE_LABEL[s].toUpperCase();
39
+ * ```
40
+ *
41
+ * **Sentence case, deliberately.** A template that wants caps applies its own
42
+ * `.toUpperCase()`, and a template that wants Title Case is already there;
43
+ * shipping the shouted form instead would leave the two chromes that do not
44
+ * shout with a string they have to un-shout, which no case transform does
45
+ * correctly.
46
+ *
47
+ * Two wordings are decisions rather than transliterations of the member name:
48
+ *
49
+ * - `disconnected` is **"Idle"**. It is the state a session is in BEFORE it has
50
+ * ever started as well as after it ends, so it is the first word most callers
51
+ * see; "Disconnected" reads as a fault on a page where nothing has gone
52
+ * wrong yet. Both chromes that mapped this state by hand chose "Idle" too.
53
+ * - `connecting` and `thinking` carry an ellipsis, `listening` and `speaking`
54
+ * do not. The first two are waits with nothing for the caller to do; the
55
+ * other two describe someone actually talking. Same distinction
56
+ * `WORKFLOW_STATUS_LABELS` draws with its one "Working…".
57
+ *
58
+ * @public
59
+ */
60
+ export declare const AGENT_STATE_LABELS: Readonly<Record<AgentState, string>>;