@alexkroman1/aai-ui 0.10.2 → 0.10.4

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.
@@ -2,16 +2,16 @@ import type * as preact from "preact";
2
2
  /**
3
3
  * The default top-level UI component for an AAI voice agent.
4
4
  * Renders a {@link StartScreen} (with the AAI logo or a custom title from
5
- * {@link useMountConfig}) followed by a {@link ChatView} once the session starts.
5
+ * {@link useClientConfig}) followed by a {@link ChatView} once the session starts.
6
6
  *
7
- * This is the component rendered by {@link mount} when no custom component is
7
+ * This is the component rendered by {@link defineClient} when no custom component is
8
8
  * provided.
9
9
  *
10
10
  * @example
11
11
  * ```tsx
12
- * import { App, mount } from "@aai/ui";
12
+ * import { App, defineClient } from "@aai/ui";
13
13
  *
14
- * mount(App, { target: "#app", title: "My Agent" });
14
+ * defineClient(App, { target: "#app", title: "My Agent" });
15
15
  * ```
16
16
  *
17
17
  * @param className - Additional CSS class names applied to the root element.
@@ -1,4 +1,4 @@
1
- import { useMountConfig } from "../mount-context.js";
1
+ import { useClientConfig } from "../client-context.js";
2
2
  import { ChatView } from "./chat-view.js";
3
3
  import { StartScreen } from "./start-screen.js";
4
4
  import { jsx } from "preact/jsx-runtime";
@@ -12,16 +12,16 @@ function AnsiLogo() {
12
12
  /**
13
13
  * The default top-level UI component for an AAI voice agent.
14
14
  * Renders a {@link StartScreen} (with the AAI logo or a custom title from
15
- * {@link useMountConfig}) followed by a {@link ChatView} once the session starts.
15
+ * {@link useClientConfig}) followed by a {@link ChatView} once the session starts.
16
16
  *
17
- * This is the component rendered by {@link mount} when no custom component is
17
+ * This is the component rendered by {@link defineClient} when no custom component is
18
18
  * provided.
19
19
  *
20
20
  * @example
21
21
  * ```tsx
22
- * import { App, mount } from "@aai/ui";
22
+ * import { App, defineClient } from "@aai/ui";
23
23
  *
24
- * mount(App, { target: "#app", title: "My Agent" });
24
+ * defineClient(App, { target: "#app", title: "My Agent" });
25
25
  * ```
26
26
  *
27
27
  * @param className - Additional CSS class names applied to the root element.
@@ -29,7 +29,7 @@ function AnsiLogo() {
29
29
  * @public
30
30
  */
31
31
  function App({ className }) {
32
- const { title } = useMountConfig();
32
+ const { title } = useClientConfig();
33
33
  return /* @__PURE__ */ jsx(StartScreen, {
34
34
  icon: title ? void 0 : /* @__PURE__ */ jsx(AnsiLogo, {}),
35
35
  title,
@@ -1,4 +1,4 @@
1
- import { useMountConfig } from "../mount-context.js";
1
+ import { useClientConfig } from "../client-context.js";
2
2
  import { useSession } from "../signals.js";
3
3
  import { Controls } from "./controls.js";
4
4
  import { ErrorBanner } from "./error-banner.js";
@@ -34,7 +34,7 @@ import { jsx, jsxs } from "preact/jsx-runtime";
34
34
  */
35
35
  function ChatView({ className }) {
36
36
  const { session } = useSession();
37
- const { title } = useMountConfig();
37
+ const { title } = useClientConfig();
38
38
  return /* @__PURE__ */ jsxs("div", {
39
39
  class: clsx("flex flex-col h-screen max-w-130 mx-auto bg-aai-bg text-aai-text font-aai text-sm", className),
40
40
  children: [
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @public
5
5
  */
6
- export type MountTheme = {
6
+ export type ClientTheme = {
7
7
  /** Background color. Default: `#101010`. */
8
8
  bg?: string;
9
9
  /** Primary accent color. Default: `#fab283`. */
@@ -16,18 +16,18 @@ export type MountTheme = {
16
16
  border?: string;
17
17
  };
18
18
  /**
19
- * Resolved mount-level configuration available to default UI components.
19
+ * Resolved client-level configuration available to default UI components.
20
20
  *
21
21
  * @public
22
22
  */
23
- export type MountConfig = {
23
+ export type ClientConfig = {
24
24
  title?: string | undefined;
25
- theme?: MountTheme | undefined;
25
+ theme?: ClientTheme | undefined;
26
26
  };
27
- export declare const MountConfigProvider: import("preact").Provider<MountConfig>;
27
+ export declare const ClientConfigProvider: import("preact").Provider<ClientConfig>;
28
28
  /**
29
- * Read mount config (title, theme) from the nearest provider.
29
+ * Read client config (title, theme) from the nearest provider.
30
30
  *
31
31
  * @public
32
32
  */
33
- export declare function useMountConfig(): MountConfig;
33
+ export declare function useClientConfig(): ClientConfig;
@@ -0,0 +1,15 @@
1
+ import { createContext } from "preact";
2
+ import { useContext } from "preact/hooks";
3
+ //#region client-context.ts
4
+ const Ctx = createContext({});
5
+ const ClientConfigProvider = Ctx.Provider;
6
+ /**
7
+ * Read client config (title, theme) from the nearest provider.
8
+ *
9
+ * @public
10
+ */
11
+ function useClientConfig() {
12
+ return useContext(Ctx);
13
+ }
14
+ //#endregion
15
+ export { ClientConfigProvider, useClientConfig };
@@ -1,13 +1,13 @@
1
1
  import type { ComponentType } from "preact";
2
- import { type MountTheme } from "./mount-context.ts";
3
- import { type VoiceSession } from "./session.ts";
2
+ import { type ClientTheme } from "./client-context.ts";
3
+ import { type VoiceSession, type WebSocketConstructor } from "./session.ts";
4
4
  import { type SessionSignals } from "./signals.ts";
5
5
  /**
6
- * Options for {@link mount}.
6
+ * Options for {@link defineClient}.
7
7
  *
8
8
  * @public
9
9
  */
10
- export type MountOptions = {
10
+ export type ClientOptions = {
11
11
  /** CSS selector or DOM element to render into. Defaults to `"#app"`. */
12
12
  target?: string | HTMLElement;
13
13
  /** Base URL of the AAI platform server. Derived from `location.href` by default. */
@@ -15,20 +15,22 @@ export type MountOptions = {
15
15
  /** Agent title shown in the header and start screen. */
16
16
  title?: string;
17
17
  /** Theme color overrides. */
18
- theme?: MountTheme;
18
+ theme?: ClientTheme;
19
19
  /** Called when the server sends a session ID. Store it for reconnection. */
20
20
  onSessionId?: ((sessionId: string) => void) | undefined;
21
21
  /** Session ID from a previous connection for resuming persisted state. */
22
22
  resumeSessionId?: string | undefined;
23
+ /** WebSocket constructor override. Passed through to VoiceSessionOptions. */
24
+ WebSocket?: WebSocketConstructor | undefined;
23
25
  };
24
26
  /**
25
- * Handle returned by {@link mount} for cleanup.
27
+ * Handle returned by {@link defineClient} for cleanup.
26
28
  *
27
29
  * Implements `Disposable` so it can be used with `using`.
28
30
  *
29
31
  * @public
30
32
  */
31
- export type MountHandle = {
33
+ export type ClientHandle = {
32
34
  /** The underlying voice session. */
33
35
  session: VoiceSession;
34
36
  /** Reactive session controls for the mounted UI. */
@@ -39,17 +41,17 @@ export type MountHandle = {
39
41
  [Symbol.dispose](): void;
40
42
  };
41
43
  /**
42
- * Mount a Preact component with voice session wiring.
44
+ * Define and mount a client UI for a voice agent.
43
45
  *
44
46
  * Creates a {@link VoiceSession}, wraps it in
45
47
  * {@link SessionSignals}, and renders the component
46
48
  * inside a {@link SessionProvider}.
47
49
  *
48
50
  * @param Component - The Preact component to render.
49
- * @param options - Mount options (target element, platform URL).
50
- * @returns A {@link MountHandle} for cleanup.
51
+ * @param options - Client options (target element, platform URL, theme).
52
+ * @returns A {@link ClientHandle} for cleanup.
51
53
  * @throws If the target element is not found in the DOM.
52
54
  *
53
55
  * @public
54
56
  */
55
- export declare function mount(Component: ComponentType<any>, options?: MountOptions): MountHandle;
57
+ export declare function defineClient(Component: ComponentType<any>, options?: ClientOptions): ClientHandle;
@@ -1,10 +1,10 @@
1
- import { MountConfigProvider } from "./mount-context.js";
1
+ import { ClientConfigProvider } from "./client-context.js";
2
2
  import { SessionProvider, createSessionControls } from "./signals.js";
3
- import { t as createVoiceSession } from "./session-D0RdWlWH.js";
3
+ import { t as createVoiceSession } from "./session-CWB7vmqz.js";
4
4
  import { render } from "preact";
5
5
  import { batch, signal } from "@preact/signals";
6
6
  import { jsx } from "preact/jsx-runtime";
7
- //#region mount.tsx
7
+ //#region define-client.tsx
8
8
  function resolveContainer(target = "#app") {
9
9
  if (typeof target !== "string") return target;
10
10
  const el = document.querySelector(target);
@@ -12,30 +12,31 @@ function resolveContainer(target = "#app") {
12
12
  return el;
13
13
  }
14
14
  /**
15
- * Mount a Preact component with voice session wiring.
15
+ * Define and mount a client UI for a voice agent.
16
16
  *
17
17
  * Creates a {@link VoiceSession}, wraps it in
18
18
  * {@link SessionSignals}, and renders the component
19
19
  * inside a {@link SessionProvider}.
20
20
  *
21
21
  * @param Component - The Preact component to render.
22
- * @param options - Mount options (target element, platform URL).
23
- * @returns A {@link MountHandle} for cleanup.
22
+ * @param options - Client options (target element, platform URL, theme).
23
+ * @returns A {@link ClientHandle} for cleanup.
24
24
  * @throws If the target element is not found in the DOM.
25
25
  *
26
26
  * @public
27
27
  */
28
- function mount(Component, options) {
28
+ function defineClient(Component, options) {
29
29
  const container = resolveContainer(options?.target);
30
30
  const session = createVoiceSession({
31
31
  platformUrl: options?.platformUrl ?? globalThis.location.origin + globalThis.location.pathname,
32
32
  reactiveFactory: signal,
33
33
  batch,
34
34
  onSessionId: options?.onSessionId,
35
- resumeSessionId: options?.resumeSessionId
35
+ resumeSessionId: options?.resumeSessionId,
36
+ ...options?.WebSocket ? { WebSocket: options.WebSocket } : {}
36
37
  });
37
38
  const signals = createSessionControls(session);
38
- const mountConfig = {
39
+ const clientConfig = {
39
40
  title: options?.title,
40
41
  theme: options?.theme
41
42
  };
@@ -48,8 +49,8 @@ function mount(Component, options) {
48
49
  if (t.surface) el.style.setProperty("--color-aai-surface", t.surface);
49
50
  if (t.border) el.style.setProperty("--color-aai-border", t.border);
50
51
  }
51
- render(/* @__PURE__ */ jsx(MountConfigProvider, {
52
- value: mountConfig,
52
+ render(/* @__PURE__ */ jsx(ClientConfigProvider, {
53
+ value: clientConfig,
53
54
  children: /* @__PURE__ */ jsx(SessionProvider, {
54
55
  value: signals,
55
56
  children: /* @__PURE__ */ jsx(Component, {})
@@ -70,4 +71,4 @@ function mount(Component, options) {
70
71
  return handle;
71
72
  }
72
73
  //#endregion
73
- export { mount };
74
+ export { defineClient };
package/dist/index.d.ts CHANGED
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * @example
9
9
  * ```tsx
10
- * import { App, mount } from "@aai/ui";
10
+ * import { App, defineClient } from "@aai/ui";
11
11
  *
12
- * mount(App, { target: "#app" });
12
+ * defineClient(App, { target: "#app" });
13
13
  * ```
14
14
  */
15
15
  export { App } from "./_components/app.tsx";
@@ -26,12 +26,12 @@ export { StateIndicator } from "./_components/state-indicator.tsx";
26
26
  export { ThinkingIndicator } from "./_components/thinking-indicator.tsx";
27
27
  export { ToolCallBlock } from "./_components/tool-call-block.tsx";
28
28
  export { Transcript } from "./_components/transcript.tsx";
29
- export type { MountHandle, MountOptions } from "./mount.tsx";
30
- export { mount } from "./mount.tsx";
31
- export type { MountConfig, MountTheme } from "./mount-context.ts";
32
- export { useMountConfig } from "./mount-context.ts";
29
+ export type { ClientConfig, ClientTheme } from "./client-context.ts";
30
+ export { useClientConfig } from "./client-context.ts";
31
+ export type { ClientHandle, ClientOptions } from "./define-client.tsx";
32
+ export { defineClient } from "./define-client.tsx";
33
33
  export type { VoiceSession } from "./session.ts";
34
34
  export { createVoiceSession } from "./session.ts";
35
35
  export type { SessionSignals } from "./signals.ts";
36
- export { createSessionControls, SessionProvider, useAutoScroll, useSession, useToolCallStart, useToolCallUpdate, useToolResult, } from "./signals.ts";
36
+ export { createSessionControls, SessionProvider, useAutoScroll, useSession, useToolCallStart, useToolResult, } from "./signals.ts";
37
37
  export type { AgentState, ChatMessage, Reactive, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, } from "./types.ts";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { useMountConfig } from "./mount-context.js";
2
- import { SessionProvider, createSessionControls, useAutoScroll, useSession, useToolCallStart, useToolCallUpdate, useToolResult } from "./signals.js";
1
+ import { useClientConfig } from "./client-context.js";
2
+ import { SessionProvider, createSessionControls, useAutoScroll, useSession, useToolCallStart, useToolResult } from "./signals.js";
3
3
  import { Button } from "./_components/button.js";
4
4
  import { Controls } from "./_components/controls.js";
5
5
  import { ErrorBanner } from "./_components/error-banner.js";
@@ -13,6 +13,6 @@ import { ChatView } from "./_components/chat-view.js";
13
13
  import { StartScreen } from "./_components/start-screen.js";
14
14
  import { App } from "./_components/app.js";
15
15
  import { SidebarLayout } from "./_components/sidebar-layout.js";
16
- import { t as createVoiceSession } from "./session-D0RdWlWH.js";
17
- import { mount } from "./mount.js";
18
- export { App, Button, ChatView, Controls, ErrorBanner, MessageBubble, MessageList, SessionProvider, SidebarLayout, StartScreen, StateIndicator, ThinkingIndicator, ToolCallBlock, Transcript, createSessionControls, createVoiceSession, mount, useAutoScroll, useMountConfig, useSession, useToolCallStart, useToolCallUpdate, useToolResult };
16
+ import { t as createVoiceSession } from "./session-CWB7vmqz.js";
17
+ import { defineClient } from "./define-client.js";
18
+ export { App, Button, ChatView, Controls, ErrorBanner, MessageBubble, MessageList, SessionProvider, SidebarLayout, StartScreen, StateIndicator, ThinkingIndicator, ToolCallBlock, Transcript, createSessionControls, createVoiceSession, defineClient, useAutoScroll, useClientConfig, useSession, useToolCallStart, useToolResult };
@@ -72,24 +72,9 @@ var ClientHandler = class {
72
72
  toolName: e.toolName,
73
73
  args: e.args,
74
74
  status: "pending",
75
- updates: [],
76
75
  afterMessageIndex: this.#messages.value.length - 1
77
76
  }];
78
77
  break;
79
- case "tool_call_update": {
80
- const tcs = this.#toolCalls.value;
81
- const idx = tcs.findIndex((tc) => tc.toolCallId === e.toolCallId);
82
- if (idx !== -1) {
83
- const updated = [...tcs];
84
- const existing = updated[idx];
85
- if (existing) updated[idx] = {
86
- ...existing,
87
- updates: [...existing.updates, e.data]
88
- };
89
- this.#toolCalls.value = updated;
90
- }
91
- break;
92
- }
93
78
  case "tool_call_done": {
94
79
  const tcs = this.#toolCalls.value;
95
80
  const idx = tcs.findIndex((tc) => tc.toolCallId === e.toolCallId);
@@ -208,14 +193,7 @@ var ClientHandler = class {
208
193
  };
209
194
  //#endregion
210
195
  //#region session.ts
211
- /** Built-in non-reactive container (plain mutable wrapper). */
212
- function plainReactive(initial) {
213
- return { value: initial };
214
- }
215
- /** No-op batch — just calls the function. */
216
- function plainBatch(fn) {
217
- fn();
218
- }
196
+ const WS_OPEN = 1;
219
197
  /**
220
198
  * Initialize audio capture and playback after the server sends a ready config.
221
199
  *
@@ -261,7 +239,7 @@ async function initAudioCapture(conn, msg, deps) {
261
239
  }
262
240
  }
263
241
  });
264
- if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) {
242
+ if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN) {
265
243
  io.close();
266
244
  return;
267
245
  }
@@ -269,7 +247,7 @@ async function initAudioCapture(conn, msg, deps) {
269
247
  deps.send({ type: "audio_ready" });
270
248
  deps.state.value = "listening";
271
249
  } catch (err) {
272
- if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WebSocket.OPEN) return;
250
+ if (conn.generation !== gen || !conn.ws || conn.ws.readyState !== WS_OPEN) return;
273
251
  deps.batch(() => {
274
252
  deps.error.value = {
275
253
  code: "audio",
@@ -299,8 +277,9 @@ function buildWsUrl(platformUrl, resume, sessionId) {
299
277
  * @public
300
278
  */
301
279
  function createVoiceSession(options) {
302
- const reactive = options.reactiveFactory ?? plainReactive;
303
- const batchFn = options.batch ?? plainBatch;
280
+ const WS = options.WebSocket ?? WebSocket;
281
+ const reactive = options.reactiveFactory ?? ((initial) => ({ value: initial }));
282
+ const batchFn = options.batch ?? ((fn) => fn());
304
283
  const state = reactive("disconnected");
305
284
  const messages = reactive([]);
306
285
  const toolCalls = reactive([]);
@@ -331,10 +310,10 @@ function createVoiceSession(options) {
331
310
  });
332
311
  }
333
312
  function send(msg) {
334
- if (conn.ws && conn.ws.readyState === WebSocket.OPEN) conn.ws.send(JSON.stringify(msg));
313
+ if (conn.ws && conn.ws.readyState === WS_OPEN) conn.ws.send(JSON.stringify(msg));
335
314
  }
336
315
  function sendBinary(data) {
337
- if (conn.ws && conn.ws.readyState === WebSocket.OPEN) conn.ws.send(data);
316
+ if (conn.ws && conn.ws.readyState === WS_OPEN) conn.ws.send(data);
338
317
  }
339
318
  const audioDeps = {
340
319
  send,
@@ -356,8 +335,7 @@ function createVoiceSession(options) {
356
335
  const { signal: sig } = controller;
357
336
  if (opts?.signal) opts.signal.addEventListener("abort", () => disconnect(), { signal: sig });
358
337
  const resumeId = !hasConnected ? options.resumeSessionId : void 0;
359
- const wsUrl = buildWsUrl(options.platformUrl, hasConnected, resumeId);
360
- const socket = new WebSocket(wsUrl.toString());
338
+ const socket = new WS(buildWsUrl(options.platformUrl, hasConnected, resumeId).toString());
361
339
  socket.binaryType = "arraybuffer";
362
340
  conn.ws = socket;
363
341
  const handler = new ClientHandler({
@@ -412,7 +390,7 @@ function createVoiceSession(options) {
412
390
  }
413
391
  function reset() {
414
392
  conn.voiceIO?.flush();
415
- if (conn.ws && conn.ws.readyState === WebSocket.OPEN) {
393
+ if (conn.ws && conn.ws.readyState === WS_OPEN) {
416
394
  send({ type: "reset" });
417
395
  return;
418
396
  }
package/dist/session.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AgentState, ChatMessage, Reactive, SessionError, ToolCallInfo, VoiceSessionOptions } from "./types.ts";
2
2
  export { ClientHandler } from "./client-handler.ts";
3
- export type { AgentState, ChatMessage, Reactive, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, } from "./types.ts";
3
+ export type { AgentState, ChatMessage, Reactive, SessionError, SessionErrorCode, ToolCallInfo, VoiceSessionOptions, WebSocketConstructor, } from "./types.ts";
4
4
  /**
5
5
  * A reactive voice session that manages WebSocket communication,
6
6
  * audio capture/playback, and agent state transitions.
package/dist/session.js CHANGED
@@ -1,2 +1,2 @@
1
- import { n as ClientHandler, t as createVoiceSession } from "./session-D0RdWlWH.js";
1
+ import { n as ClientHandler, t as createVoiceSession } from "./session-CWB7vmqz.js";
2
2
  export { ClientHandler, createVoiceSession };
package/dist/signals.d.ts CHANGED
@@ -110,23 +110,6 @@ export declare function useToolResult(callback: (toolName: string, result: unkno
110
110
  * @public
111
111
  */
112
112
  export declare function useToolCallStart(callback: (toolName: string, args: Record<string, unknown>, toolCall: ToolCallInfo) => void): void;
113
- /**
114
- * Hook that fires a callback for each intermediate update pushed by a tool
115
- * via `ctx.sendUpdate()`.
116
- *
117
- * Use this to render progressive/streaming data from a tool before it
118
- * finishes — e.g. showing a recipe preview card while nutrition data is
119
- * still loading.
120
- *
121
- * The callback fires once per update. The `data` argument is the parsed
122
- * JSON update (or raw string if parsing fails).
123
- *
124
- * @param callback - Called once per intermediate update with the tool name,
125
- * parsed data, and full {@link ToolCallInfo}.
126
- *
127
- * @public
128
- */
129
- export declare function useToolCallUpdate(callback: (toolName: string, data: unknown, toolCall: ToolCallInfo) => void): void;
130
113
  /**
131
114
  * Auto-scroll a container to the bottom when messages, tool calls,
132
115
  * or utterances change. Returns a ref to attach to a sentinel `<div>`
package/dist/signals.js CHANGED
@@ -86,13 +86,23 @@ function isNewCompletedCall(tc, seen, filterName) {
86
86
  if (filterName && tc.toolName !== filterName) return false;
87
87
  return true;
88
88
  }
89
- function useToolResult(toolNameOrCallback, maybeCallback) {
90
- const filterName = typeof toolNameOrCallback === "string" ? toolNameOrCallback : void 0;
91
- const callback = typeof toolNameOrCallback === "function" ? toolNameOrCallback : (_name, result, tc) => maybeCallback?.(result, tc);
92
- const { session } = useSession();
89
+ /**
90
+ * Shared helper for hooks that need to fire a callback once per new tool call.
91
+ *
92
+ * Handles deduplication via a `Set<string>` ref, automatic reset when the
93
+ * signal array is cleared, and a stable callback ref to avoid stale closures.
94
+ *
95
+ * @param session - The voice session whose `toolCalls` signal to watch.
96
+ * @param shouldProcess - Predicate that decides whether a tool call is eligible.
97
+ * Called with the tool call and the seen-IDs set. Must NOT mutate the set.
98
+ * @param onNew - Invoked for each eligible tool call that hasn't been seen yet.
99
+ */
100
+ function useToolCallEffect(session, shouldProcess, onNew) {
93
101
  const seenRef = useRef(/* @__PURE__ */ new Set());
94
- const cbRef = useRef(callback);
95
- cbRef.current = callback;
102
+ const cbRef = useRef(onNew);
103
+ cbRef.current = onNew;
104
+ const predicateRef = useRef(shouldProcess);
105
+ predicateRef.current = shouldProcess;
96
106
  useEffect(() => effect(() => {
97
107
  const toolCalls = session.toolCalls.value;
98
108
  if (toolCalls.length === 0) {
@@ -100,12 +110,19 @@ function useToolResult(toolNameOrCallback, maybeCallback) {
100
110
  return;
101
111
  }
102
112
  for (const tc of toolCalls) {
103
- if (!isNewCompletedCall(tc, seenRef.current, filterName)) continue;
113
+ if (!predicateRef.current(tc, seenRef.current)) continue;
114
+ if (seenRef.current.has(tc.toolCallId)) continue;
104
115
  seenRef.current.add(tc.toolCallId);
105
- cbRef.current(tc.toolName, tryParseJSON(tc.result), tc);
116
+ cbRef.current(tc);
106
117
  }
107
118
  }), [session]);
108
119
  }
120
+ function useToolResult(toolNameOrCallback, maybeCallback) {
121
+ const filterName = typeof toolNameOrCallback === "string" ? toolNameOrCallback : void 0;
122
+ const callback = typeof toolNameOrCallback === "function" ? toolNameOrCallback : (_name, result, tc) => maybeCallback?.(result, tc);
123
+ const { session } = useSession();
124
+ useToolCallEffect(session, (tc, seen) => isNewCompletedCall(tc, seen, filterName), (tc) => callback(tc.toolName, tryParseJSON(tc.result), tc));
125
+ }
109
126
  /**
110
127
  * Hook that fires a callback when a new tool call starts (status: "pending").
111
128
  *
@@ -119,60 +136,7 @@ function useToolResult(toolNameOrCallback, maybeCallback) {
119
136
  */
120
137
  function useToolCallStart(callback) {
121
138
  const { session } = useSession();
122
- const seenRef = useRef(/* @__PURE__ */ new Set());
123
- const cbRef = useRef(callback);
124
- cbRef.current = callback;
125
- useEffect(() => effect(() => {
126
- const toolCalls = session.toolCalls.value;
127
- if (toolCalls.length === 0) {
128
- seenRef.current.clear();
129
- return;
130
- }
131
- for (const tc of toolCalls) {
132
- if (seenRef.current.has(tc.toolCallId)) continue;
133
- seenRef.current.add(tc.toolCallId);
134
- cbRef.current(tc.toolName, tc.args, tc);
135
- }
136
- }), [session]);
137
- }
138
- /**
139
- * Hook that fires a callback for each intermediate update pushed by a tool
140
- * via `ctx.sendUpdate()`.
141
- *
142
- * Use this to render progressive/streaming data from a tool before it
143
- * finishes — e.g. showing a recipe preview card while nutrition data is
144
- * still loading.
145
- *
146
- * The callback fires once per update. The `data` argument is the parsed
147
- * JSON update (or raw string if parsing fails).
148
- *
149
- * @param callback - Called once per intermediate update with the tool name,
150
- * parsed data, and full {@link ToolCallInfo}.
151
- *
152
- * @public
153
- */
154
- function useToolCallUpdate(callback) {
155
- const { session } = useSession();
156
- const countRef = useRef(/* @__PURE__ */ new Map());
157
- const cbRef = useRef(callback);
158
- cbRef.current = callback;
159
- useEffect(() => effect(() => {
160
- const toolCalls = session.toolCalls.value;
161
- if (toolCalls.length === 0) {
162
- countRef.current.clear();
163
- return;
164
- }
165
- for (const tc of toolCalls) processNewUpdates(tc, countRef.current, cbRef.current);
166
- }), [session]);
167
- }
168
- function processNewUpdates(tc, seenCounts, cb) {
169
- const seen = seenCounts.get(tc.toolCallId) ?? 0;
170
- if (tc.updates.length <= seen) return;
171
- for (let i = seen; i < tc.updates.length; i++) {
172
- const raw = tc.updates[i];
173
- if (raw !== void 0) cb(tc.toolName, tryParseJSON(raw), tc);
174
- }
175
- seenCounts.set(tc.toolCallId, tc.updates.length);
139
+ useToolCallEffect(session, () => true, (tc) => callback(tc.toolName, tc.args, tc));
176
140
  }
177
141
  /**
178
142
  * Auto-scroll a container to the bottom when messages, tool calls,
@@ -194,4 +158,4 @@ function useAutoScroll() {
194
158
  return ref;
195
159
  }
196
160
  //#endregion
197
- export { SessionProvider, createSessionControls, useAutoScroll, useSession, useToolCallStart, useToolCallUpdate, useToolResult };
161
+ export { SessionProvider, createSessionControls, useAutoScroll, useSession, useToolCallStart, useToolResult };
package/dist/types.d.ts CHANGED
@@ -29,8 +29,6 @@ export type ToolCallInfo = {
29
29
  args: Record<string, unknown>;
30
30
  status: "pending" | "done";
31
31
  result?: string | undefined;
32
- /** Intermediate updates pushed by the tool via `ctx.sendUpdate()`. */
33
- updates: string[];
34
32
  /** Index in the messages array where this tool call should appear. */
35
33
  afterMessageIndex: number;
36
34
  };
@@ -86,4 +84,18 @@ export type VoiceSessionOptions = {
86
84
  * `persistence` enabled).
87
85
  */
88
86
  resumeSessionId?: string | undefined;
87
+ /**
88
+ * WebSocket constructor override. Defaults to the native `WebSocket`.
89
+ * Primarily useful for testing with a mock WebSocket.
90
+ */
91
+ WebSocket?: WebSocketConstructor | undefined;
92
+ };
93
+ /**
94
+ * Minimal WebSocket constructor type accepted by {@link VoiceSessionOptions}.
95
+ *
96
+ * @public
97
+ */
98
+ export type WebSocketConstructor = {
99
+ new (url: string | URL, protocols?: string | string[]): WebSocket;
100
+ readonly OPEN: number;
89
101
  };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Type-level tests for the public API surface of @alexkroman1/aai-ui.
3
+ *
4
+ * These are checked by tsc (via vitest typecheck) but never executed.
5
+ * A failure here means a public type contract has regressed.
6
+ */
7
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexkroman1/aai-ui",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "clsx": "^2.1.1",
24
- "@alexkroman1/aai": "0.10.2"
24
+ "@alexkroman1/aai": "0.10.4"
25
25
  },
26
26
  "peerDependencies": {
27
27
  "@preact/signals": "^2.8.2",
@@ -38,7 +38,8 @@
38
38
  "@testing-library/preact": "^3.2.4",
39
39
  "jsdom": "^29.0.1",
40
40
  "preact": "^10.29.0",
41
- "tsdown": "^0.21.5"
41
+ "tsdown": "^0.21.5",
42
+ "vitest": "^4.1.1"
42
43
  },
43
44
  "engines": {
44
45
  "node": ">=22.6"
@@ -52,7 +53,7 @@
52
53
  "build": "tsdown && tsc -p tsconfig.build.json",
53
54
  "typecheck": "tsc --noEmit",
54
55
  "lint": "biome check .",
55
- "check:api": "api-extractor run -c api-extractor.json",
56
+ "check:publint": "publint",
56
57
  "check:attw": "attw --pack --profile esm-only --entrypoints . ./session"
57
58
  }
58
59
  }
@@ -1,3 +0,0 @@
1
- export declare class DatabaseSync {
2
- constructor();
3
- }
@@ -1,15 +0,0 @@
1
- import { createContext } from "preact";
2
- import { useContext } from "preact/hooks";
3
- //#region mount-context.ts
4
- const Ctx = createContext({});
5
- const MountConfigProvider = Ctx.Provider;
6
- /**
7
- * Read mount config (title, theme) from the nearest provider.
8
- *
9
- * @public
10
- */
11
- function useMountConfig() {
12
- return useContext(Ctx);
13
- }
14
- //#endregion
15
- export { MountConfigProvider, useMountConfig };