@alexkroman1/aai-ui 1.9.2 → 1.10.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 (44) hide show
  1. package/dist/{_colors-DJUordGv.js → _colors-DYX7XRTr.js} +8 -0
  2. package/dist/{_utils-5cs73OrA.js → _utils-CN1yVgYS.js} +2 -5
  3. package/dist/audio.d.ts +6 -0
  4. package/dist/audio.js +62 -9
  5. package/dist/{chat-view-C1XbqDk0.js → chat-view-u3yBAlig.js} +16 -14
  6. package/dist/components/_colors.d.ts +0 -4
  7. package/dist/components/chat-view.js +1 -1
  8. package/dist/components/controls.d.ts +2 -2
  9. package/dist/components/controls.js +1 -1
  10. package/dist/components/message-list.d.ts +2 -2
  11. package/dist/components/message-list.js +70 -64
  12. package/dist/components/start-screen.js +1 -1
  13. package/dist/components/text-controls.d.ts +2 -2
  14. package/dist/components/tool-call-block.d.ts +6 -2
  15. package/dist/components/tool-call-block.js +1 -1
  16. package/dist/context.d.ts +3 -11
  17. package/dist/context.js +9 -22
  18. package/dist/{controls-BngrPbOC.js → controls-B2EPUJDU.js} +12 -9
  19. package/dist/default-client/assets/audio-COt0_Zvp.js +1 -0
  20. package/dist/default-client/assets/{capture-processor-C19oBn4L.js → capture-processor-UlKEKyIW.js} +4 -1
  21. package/dist/default-client/assets/index-B2bISVbm.css +2 -0
  22. package/dist/default-client/assets/index-DtsWxxc_.js +94 -0
  23. package/dist/default-client/assets/{playback-processor-BtlzAH78.js → playback-processor-C5HVRVbu.js} +11 -4
  24. package/dist/default-client/index.html +2 -2
  25. package/dist/define-client.js +3 -3
  26. package/dist/hooks.js +5 -2
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.js +5 -5
  29. package/dist/{session-core-D3NDaySY.js → session-core-Cfz7PPhN.js} +248 -111
  30. package/dist/session-core-audio-setup.d.ts +36 -0
  31. package/dist/session-core-messages.d.ts +12 -0
  32. package/dist/session-core-reconnect.d.ts +19 -0
  33. package/dist/session-core-url.d.ts +2 -0
  34. package/dist/session-core.js +1 -1
  35. package/dist/{tool-call-block-7f1GTG-P.js → tool-call-block-DIxpG8GM.js} +11 -6
  36. package/dist/types.d.ts +11 -3
  37. package/dist/worklets/capture-processor.d.ts +1 -1
  38. package/dist/worklets/capture-processor.js +4 -1
  39. package/dist/worklets/playback-processor.d.ts +1 -1
  40. package/dist/worklets/playback-processor.js +11 -4
  41. package/package.json +8 -5
  42. package/dist/default-client/assets/audio-Cs-6t_Wd.js +0 -1
  43. package/dist/default-client/assets/index-Bf4ZTNcx.js +0 -73
  44. package/dist/default-client/assets/index-Bzlh9i7w.css +0 -2
@@ -1,4 +1,12 @@
1
1
  //#region components/_colors.ts
2
+ /**
3
+ * Shared tints used by the default components (AssemblyAI design system,
4
+ * "website refresh": warm neutrals over the cream/white surfaces).
5
+ *
6
+ * These sit on top of the {@link ClientTheme} colors (which own the opaque
7
+ * palette) and are intentionally not themeable: they are the warm-gray text
8
+ * steps and ink alpha layers the refresh uses over any light surface.
9
+ */
2
10
  /** Muted text — subtitles, secondary labels, thinking dots (fg-muted). */
3
11
  const TEXT_MUTED = "#57534B";
4
12
  /** Faint text — live transcripts, state indicator, start-screen subtitle (warm-500). */
@@ -1,12 +1,9 @@
1
+ import { safeJsonParse } from "@alexkroman1/aai";
1
2
  //#region _utils.ts
2
3
  /** Parse a JSON string, returning the input unchanged when it isn't valid JSON. */
3
4
  function tryParseJSON(str) {
4
5
  if (!str) return str;
5
- try {
6
- return JSON.parse(str);
7
- } catch {
8
- return str;
9
- }
6
+ return safeJsonParse(str) ?? str;
10
7
  }
11
8
  /** Truncate a string to `max` characters, appending an ellipsis when cut. */
12
9
  function truncate(s, max = 80) {
package/dist/audio.d.ts CHANGED
@@ -19,6 +19,12 @@ export type VoiceIOOptions = {
19
19
  playbackWorkletSrc: string;
20
20
  /** Callback invoked with buffered PCM16 microphone data to send to the server. */
21
21
  onMicData: (pcm16: ArrayBuffer) => void;
22
+ /**
23
+ * Called when an AudioWorklet processor throws and is killed by the browser
24
+ * (the node produces no further audio or messages), so the session can
25
+ * transition out of listening/speaking instead of looking healthy forever.
26
+ */
27
+ onError?: ((err: Error) => void) | undefined;
22
28
  };
23
29
  /**
24
30
  * Audio I/O interface for voice capture and playback.
package/dist/audio.js CHANGED
@@ -1,5 +1,19 @@
1
1
  import { MIC_BUFFER_SECONDS } from "./types.js";
2
2
  //#region audio.ts
3
+ /** How often {@link VoiceIO.done} checks that the AudioContext is still rendering. */
4
+ const DONE_POLL_INTERVAL_MS = 1e3;
5
+ /**
6
+ * Hard cap on waiting for playback to drain. The playback worklet buffers up
7
+ * to 60s of audio, so the longest legitimate drain is just under that — a
8
+ * wait past this means the processor died without reporting 'stop'.
9
+ */
10
+ const DONE_MAX_WAIT_MS = 65e3;
11
+ /**
12
+ * Bounded wait for the capture worklet's 'stopped' ack during close(). The
13
+ * ack follows the final flush, so waiting for it keeps the tail of speech
14
+ * from being dropped; the timeout covers a dead worklet.
15
+ */
16
+ const CAPTURE_STOP_ACK_TIMEOUT_MS = 250;
3
17
  /**
4
18
  * Decode an audio file (any container/codec the browser can decode) and
5
19
  * resample it to mono PCM16 at `targetRate` — the format the server's STT
@@ -18,9 +32,10 @@ async function decodeAudioToPcm16(data, targetRate) {
18
32
  source.start();
19
33
  const f32 = (await offline.startRendering()).getChannelData(0);
20
34
  const pcm = new Int16Array(f32.length);
21
- for (let i = 0; i < f32.length; i++) {
22
- const s = Math.max(-1, Math.min(1, f32[i] ?? 0));
23
- pcm[i] = s < 0 ? s * 32768 : s * 32767;
35
+ let i = 0;
36
+ for (const sample of f32) {
37
+ const s = Math.max(-1, Math.min(1, sample));
38
+ pcm[i++] = s < 0 ? s * 32768 : s * 32767;
24
39
  }
25
40
  return pcm;
26
41
  }
@@ -36,7 +51,7 @@ async function decodeAudioToPcm16(data, targetRate) {
36
51
  * @throws If microphone access is denied or AudioWorklet registration fails.
37
52
  */
38
53
  async function createVoiceIO(opts) {
39
- const { sttSampleRate, ttsSampleRate, captureWorkletSrc, playbackWorkletSrc, onMicData } = opts;
54
+ const { sttSampleRate, ttsSampleRate, captureWorkletSrc, playbackWorkletSrc, onMicData, onError } = opts;
40
55
  const contextRate = ttsSampleRate;
41
56
  const ctx = new AudioContext({
42
57
  sampleRate: contextRate,
@@ -77,10 +92,19 @@ async function createVoiceIO(opts) {
77
92
  }
78
93
  });
79
94
  mic.connect(capNode);
95
+ capNode.onprocessorerror = () => {
96
+ const err = /* @__PURE__ */ new Error("Audio capture worklet crashed");
97
+ console.error("[aai-ui]", err.message);
98
+ onError?.(err);
99
+ };
80
100
  capNode.port.postMessage({ event: "start" });
101
+ let onCaptureStopped = null;
81
102
  capNode.port.onmessage = (e) => {
82
- if (e.data.event !== "chunk") return;
83
- onMicData(e.data.buffer);
103
+ if (e.data.event === "chunk") onMicData(e.data.buffer);
104
+ else if (e.data.event === "stopped") {
105
+ onCaptureStopped?.();
106
+ onCaptureStopped = null;
107
+ }
84
108
  };
85
109
  let playNode = null;
86
110
  let onPlaybackStop = null;
@@ -91,10 +115,18 @@ async function createVoiceIO(opts) {
91
115
  node.connect(ctx.destination);
92
116
  node.port.onmessage = (e) => {
93
117
  if (e.data.event === "stop") {
118
+ if (e.data.reason === "interrupt") return;
94
119
  onPlaybackStop?.();
95
120
  onPlaybackStop = null;
96
121
  }
97
122
  };
123
+ node.onprocessorerror = () => {
124
+ const err = /* @__PURE__ */ new Error("Audio playback worklet crashed");
125
+ console.error("[aai-ui]", err.message);
126
+ onPlaybackStop?.();
127
+ onPlaybackStop = null;
128
+ onError?.(err);
129
+ };
98
130
  playNode = node;
99
131
  return node;
100
132
  }
@@ -111,17 +143,38 @@ async function createVoiceIO(opts) {
111
143
  if (!playNode) return Promise.resolve();
112
144
  if (ctx.state !== "running") return Promise.resolve();
113
145
  return new Promise((resolve) => {
114
- onPlaybackStop = resolve;
146
+ onPlaybackStop?.();
147
+ const settle = () => {
148
+ clearInterval(poll);
149
+ clearTimeout(cap);
150
+ if (onPlaybackStop === settle) onPlaybackStop = null;
151
+ resolve();
152
+ };
153
+ const poll = setInterval(() => {
154
+ if (ctx.state !== "running") settle();
155
+ }, DONE_POLL_INTERVAL_MS);
156
+ const cap = setTimeout(settle, DONE_MAX_WAIT_MS);
157
+ onPlaybackStop = settle;
115
158
  playNode?.port.postMessage({ event: "done" });
116
159
  });
117
160
  },
118
161
  flush() {
119
- if (playNode) playNode.port.postMessage({ event: "interrupt" });
162
+ if (!playNode) return;
163
+ onPlaybackStop?.();
164
+ onPlaybackStop = null;
165
+ playNode.port.postMessage({ event: "interrupt" });
120
166
  },
121
167
  async close() {
122
168
  if (lifecycle.signal.aborted) return;
123
169
  lifecycle.abort();
124
- capNode.port.postMessage({ event: "stop" });
170
+ await new Promise((resolve) => {
171
+ const cap = setTimeout(resolve, CAPTURE_STOP_ACK_TIMEOUT_MS);
172
+ onCaptureStopped = () => {
173
+ clearTimeout(cap);
174
+ resolve();
175
+ };
176
+ capNode.port.postMessage({ event: "stop" });
177
+ });
125
178
  mic.disconnect();
126
179
  capNode.disconnect();
127
180
  if (playNode) playNode.disconnect();
@@ -1,12 +1,12 @@
1
- import { useSession, useSessionCore, useSessionSelector, useTheme } from "./context.js";
1
+ import { useSessionCore, useSessionSelector, useTheme } from "./context.js";
2
2
  import { Button } from "./components/button.js";
3
- import { r as TEXT_FAINT, t as ERROR_COLOR } from "./_colors-DJUordGv.js";
3
+ import { r as TEXT_FAINT, t as ERROR_COLOR } from "./_colors-DYX7XRTr.js";
4
4
  import { t as AaiLogo } from "./aai-logo-B8lDmsut.js";
5
- import { r as SessionUrlChips, t as Controls } from "./controls-BngrPbOC.js";
5
+ import { r as SessionUrlChips, t as Controls } from "./controls-B2EPUJDU.js";
6
6
  import { t as Eyebrow } from "./eyebrow-C6ZFuiz6.js";
7
7
  import { MessageList } from "./components/message-list.js";
8
8
  import clsx from "clsx";
9
- import { useRef, useState } from "react";
9
+ import { memo, useRef, useState } from "react";
10
10
  import { jsx, jsxs } from "react/jsx-runtime";
11
11
  import { errorMessage } from "@alexkroman1/aai";
12
12
  //#region components/text-controls.tsx
@@ -22,7 +22,7 @@ import { errorMessage } from "@alexkroman1/aai";
22
22
  *
23
23
  * @public
24
24
  */
25
- function TextControls({ className }) {
25
+ const TextControls = memo(function TextControls({ className }) {
26
26
  const recording = useSessionSelector((s) => s.recording);
27
27
  const state = useSessionSelector((s) => s.state);
28
28
  const core = useSessionCore();
@@ -91,7 +91,7 @@ function TextControls({ className }) {
91
91
  children: uploadError
92
92
  })]
93
93
  });
94
- }
94
+ });
95
95
  //#endregion
96
96
  //#region components/chat-view.tsx
97
97
  /** @jsxImportSource react */
@@ -129,9 +129,11 @@ function stateColor(state, primary) {
129
129
  * @public
130
130
  */
131
131
  function ChatView({ icon, title, className }) {
132
- const session = useSession();
132
+ const state = useSessionSelector((s) => s.state);
133
+ const error = useSessionSelector((s) => s.error);
134
+ const audioOut = useSessionSelector((s) => s.audioOut);
133
135
  const theme = useTheme();
134
- const pulsing = PULSING_STATES.has(session.state);
136
+ const pulsing = PULSING_STATES.has(state);
135
137
  return /* @__PURE__ */ jsxs("div", {
136
138
  className: clsx("flex flex-col h-screen w-full max-w-190 mx-auto box-border px-6 py-8 gap-5 font-aai text-sm", className),
137
139
  style: {
@@ -150,24 +152,24 @@ function ChatView({ icon, title, className }) {
150
152
  })]
151
153
  }), /* @__PURE__ */ jsxs(Eyebrow, {
152
154
  className: "shrink-0",
153
- "data-state": session.state,
155
+ "data-state": state,
154
156
  children: [/* @__PURE__ */ jsx("span", {
155
157
  className: "w-[7px] h-[7px] rounded-full",
156
158
  style: {
157
- background: stateColor(session.state, theme.primary),
159
+ background: stateColor(state, theme.primary),
158
160
  animation: pulsing ? "aai-pulse 1.6s ease-in-out infinite" : "none"
159
161
  }
160
- }), session.state]
162
+ }), state]
161
163
  })]
162
164
  }),
163
- session.error && /* @__PURE__ */ jsx("div", {
165
+ error && /* @__PURE__ */ jsx("div", {
164
166
  className: "px-3.5 py-2.5 rounded-aai border text-[13px] leading-[130%] shrink-0",
165
167
  style: {
166
168
  borderColor: "rgba(179,38,30,0.35)",
167
169
  background: "rgba(179,38,30,0.06)",
168
170
  color: "#B3261E"
169
171
  },
170
- children: session.error.message
172
+ children: error.message
171
173
  }),
172
174
  /* @__PURE__ */ jsx("div", {
173
175
  className: "flex flex-col flex-1 min-h-0 border rounded-lg overflow-hidden",
@@ -178,7 +180,7 @@ function ChatView({ icon, title, className }) {
178
180
  },
179
181
  children: /* @__PURE__ */ jsx(MessageList, {})
180
182
  }),
181
- session.audioOut ? /* @__PURE__ */ jsx(Controls, {}) : /* @__PURE__ */ jsx(TextControls, {})
183
+ audioOut ? /* @__PURE__ */ jsx(Controls, {}) : /* @__PURE__ */ jsx(TextControls, {})
182
184
  ]
183
185
  });
184
186
  }
@@ -6,14 +6,10 @@
6
6
  * palette) and are intentionally not themeable: they are the warm-gray text
7
7
  * steps and ink alpha layers the refresh uses over any light surface.
8
8
  */
9
- /** Soft text — button labels on muted surfaces (warm-700). */
10
- export declare const TEXT_SOFT = "#3D3A35";
11
9
  /** Muted text — subtitles, secondary labels, thinking dots (fg-muted). */
12
10
  export declare const TEXT_MUTED = "#57534B";
13
11
  /** Faint text — live transcripts, state indicator, start-screen subtitle (warm-500). */
14
12
  export declare const TEXT_FAINT = "#6F6A60";
15
- /** Raised surface tint — secondary button background. */
16
- export declare const SURFACE_RAISED = "rgba(20,18,12,0.05)";
17
13
  /** Subtle surface tint — message bubbles, tool-call blocks. */
18
14
  export declare const SURFACE_TINT = "rgba(20,18,12,0.03)";
19
15
  /** Error red tuned for warm light surfaces. */
@@ -1,3 +1,3 @@
1
1
  import "../context.js";
2
- import { t as ChatView } from "../chat-view-C1XbqDk0.js";
2
+ import { t as ChatView } from "../chat-view-u3yBAlig.js";
3
3
  export { ChatView };
@@ -13,6 +13,6 @@
13
13
  *
14
14
  * @public
15
15
  */
16
- export declare function Controls({ className }: {
16
+ export declare const Controls: import("react").MemoExoticComponent<({ className }: {
17
17
  className?: string;
18
- }): import("react").JSX.Element;
18
+ }) => import("react").JSX.Element>;
@@ -1,3 +1,3 @@
1
1
  import "../context.js";
2
- import { t as Controls } from "../controls-BngrPbOC.js";
2
+ import { t as Controls } from "../controls-B2EPUJDU.js";
3
3
  export { Controls };
@@ -16,6 +16,6 @@
16
16
  *
17
17
  * @public
18
18
  */
19
- export declare function MessageList({ className }: {
19
+ export declare const MessageList: import("react").MemoExoticComponent<({ className }: {
20
20
  className?: string;
21
- }): import("react").JSX.Element;
21
+ }) => import("react").JSX.Element>;
@@ -1,8 +1,8 @@
1
- import { useSession, useTheme } from "../context.js";
2
- import { a as primaryTint, i as TEXT_MUTED, r as TEXT_FAINT } from "../_colors-DJUordGv.js";
3
- import { t as ToolCallBlock } from "../tool-call-block-7f1GTG-P.js";
1
+ import { useSessionSelector, useTheme } from "../context.js";
2
+ import { a as primaryTint, i as TEXT_MUTED, r as TEXT_FAINT } from "../_colors-DYX7XRTr.js";
3
+ import { t as ToolCallBlock } from "../tool-call-block-DIxpG8GM.js";
4
4
  import clsx from "clsx";
5
- import { useEffect, useMemo, useRef } from "react";
5
+ import { memo, useCallback, useEffect, useMemo, useRef } from "react";
6
6
  import { jsx, jsxs } from "react/jsx-runtime";
7
7
  //#region components/message-list.tsx
8
8
  /** @jsxImportSource react */
@@ -48,8 +48,15 @@ function UserBubble({ theme, color, children }) {
48
48
  })
49
49
  });
50
50
  }
51
- /** Renders a single chat message: labeled agent prose, or a user bubble. */
52
- function MessageBubble({ message, theme }) {
51
+ /**
52
+ * Renders a single chat message: labeled agent prose, or a user bubble.
53
+ *
54
+ * Memoized so appending one message re-renders one row, not the whole capped
55
+ * list: message objects and the theme are referentially stable across
56
+ * snapshots, and rows are keyed on stable ids (`ChatMessage.id`) that survive
57
+ * the sliding 200-message window.
58
+ */
59
+ const MessageBubble = memo(function MessageBubble({ message, theme }) {
53
60
  if (message.role === "user") return /* @__PURE__ */ jsx(UserBubble, {
54
61
  theme,
55
62
  color: theme.text,
@@ -67,29 +74,53 @@ function MessageBubble({ message, theme }) {
67
74
  children: message.content
68
75
  })]
69
76
  });
70
- }
77
+ });
78
+ /**
79
+ * How close to the bottom (px) the container must be for auto-scroll to stay
80
+ * engaged. Generous enough that an in-flight smooth scroll doesn't unpin.
81
+ */
82
+ const NEAR_BOTTOM_PX = 96;
71
83
  /**
72
- * Smooth-scroll to the anchor whenever the content version advances.
84
+ * Smooth-scroll to the anchor whenever the content version advances — but
85
+ * only while the container is pinned near the bottom. A user who scrolled up
86
+ * to read history isn't yanked back down by streaming updates; scrolling back
87
+ * to the bottom re-engages the auto-scroll.
73
88
  *
74
89
  * The scroll runs inside `requestAnimationFrame` and is deduped per frame:
75
90
  * several snapshot updates in one frame (transcript + message + tool call)
76
91
  * trigger a single scroll after layout instead of one forced layout each.
92
+ *
93
+ * While a partial transcript is streaming, updates arrive faster than a
94
+ * smooth scroll finishes — each restart leaves the animation perpetually
95
+ * mid-flight — so `streaming` switches to an instant jump; committed-content
96
+ * updates keep the smooth animation.
77
97
  */
78
- function useAutoScroll(contentVersion) {
79
- const ref = useRef(null);
98
+ function useAutoScroll(contentVersion, streaming) {
99
+ const anchorRef = useRef(null);
100
+ const pinnedRef = useRef(true);
80
101
  const scheduledRef = useRef(false);
102
+ const streamingRef = useRef(streaming);
103
+ streamingRef.current = streaming;
104
+ const onScroll = useCallback((event) => {
105
+ const el = event.currentTarget;
106
+ pinnedRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - NEAR_BOTTOM_PX;
107
+ }, []);
81
108
  useEffect(() => {
82
- if (contentVersion === 0 || scheduledRef.current) return;
109
+ if (contentVersion === 0 || scheduledRef.current || !pinnedRef.current) return;
83
110
  scheduledRef.current = true;
84
111
  requestAnimationFrame(() => {
85
112
  scheduledRef.current = false;
86
- ref.current?.scrollIntoView({
87
- behavior: "smooth",
113
+ if (!pinnedRef.current) return;
114
+ anchorRef.current?.scrollIntoView({
115
+ behavior: streamingRef.current ? "instant" : "smooth",
88
116
  block: "end"
89
117
  });
90
118
  });
91
119
  }, [contentVersion]);
92
- return ref;
120
+ return {
121
+ anchorRef,
122
+ onScroll
123
+ };
93
124
  }
94
125
  /**
95
126
  * Interleave messages and tool calls into render items, ordered by insertion
@@ -118,43 +149,6 @@ function interleave(messages, toolCalls, renderMessage, renderToolCall) {
118
149
  return items;
119
150
  }
120
151
  /**
121
- * Build the interleaved row elements, reusing each row's element object across
122
- * renders while its inputs are unchanged. Returning the identical element
123
- * reference lets React bail out of re-rendering that row entirely — the same
124
- * effect as wrapping the row components in `memo()` — so appending one message
125
- * re-renders one row, not the whole capped list. Rows are keyed on stable ids
126
- * (`ChatMessage.id`, `ToolCallInfo.callId`), which survive the sliding
127
- * 200-message window; message and tool-call objects are referentially stable
128
- * across snapshots, making the identity checks below sufficient.
129
- */
130
- function useChatItems(messages, toolCalls, theme) {
131
- const cacheRef = useRef(/* @__PURE__ */ new Map());
132
- return useMemo(() => {
133
- const prev = cacheRef.current;
134
- const next = /* @__PURE__ */ new Map();
135
- const rowFor = (key, data, make) => {
136
- const hit = prev.get(key);
137
- const element = hit && hit.data === data && hit.theme === theme ? hit.element : make();
138
- next.set(key, {
139
- data,
140
- theme,
141
- element
142
- });
143
- return element;
144
- };
145
- const items = interleave(messages, toolCalls, (msg) => rowFor(`m${msg.id}`, msg, () => /* @__PURE__ */ jsx(MessageBubble, {
146
- message: msg,
147
- theme
148
- }, msg.id)), (tc) => rowFor(`t${tc.callId}`, tc, () => /* @__PURE__ */ jsx(ToolCallBlock, { toolCall: tc }, tc.callId)));
149
- cacheRef.current = next;
150
- return items;
151
- }, [
152
- messages,
153
- toolCalls,
154
- theme
155
- ]);
156
- }
157
- /**
158
152
  * Scrollable list of all chat messages, tool-call blocks, live transcript,
159
153
  * streaming agent utterance, and a thinking indicator.
160
154
  *
@@ -172,27 +166,39 @@ function useChatItems(messages, toolCalls, theme) {
172
166
  *
173
167
  * @public
174
168
  */
175
- function MessageList({ className }) {
176
- const session = useSession();
169
+ const MessageList = memo(function MessageList({ className }) {
170
+ const state = useSessionSelector((s) => s.state);
171
+ const messages = useSessionSelector((s) => s.messages);
172
+ const toolCalls = useSessionSelector((s) => s.toolCalls);
173
+ const userTranscript = useSessionSelector((s) => s.userTranscript);
174
+ const agentTranscript = useSessionSelector((s) => s.agentTranscript);
175
+ const contentVersion = useSessionSelector((s) => s.contentVersion);
177
176
  const theme = useTheme();
178
177
  const showThinking = useMemo(() => {
179
- if (session.state !== "thinking") return false;
180
- const last = session.toolCalls.at(-1);
178
+ if (state !== "thinking") return false;
179
+ const last = toolCalls.at(-1);
181
180
  if (last?.status === "pending") return false;
182
- const lastMsg = session.messages.at(-1);
181
+ const lastMsg = messages.at(-1);
183
182
  return !lastMsg || lastMsg.role === "user" || Boolean(last);
184
183
  }, [
185
- session.state,
186
- session.toolCalls,
187
- session.messages
184
+ state,
185
+ toolCalls,
186
+ messages
187
+ ]);
188
+ const { anchorRef, onScroll } = useAutoScroll(contentVersion, userTranscript !== null);
189
+ const items = useMemo(() => interleave(messages, toolCalls, (msg) => /* @__PURE__ */ jsx(MessageBubble, {
190
+ message: msg,
191
+ theme
192
+ }, msg.id), (tc) => /* @__PURE__ */ jsx(ToolCallBlock, { toolCall: tc }, tc.callId)), [
193
+ messages,
194
+ toolCalls,
195
+ theme
188
196
  ]);
189
- const { messages, toolCalls, userTranscript, agentTranscript } = session;
190
- const scrollRef = useAutoScroll(session.contentVersion);
191
- const items = useChatItems(messages, toolCalls, theme);
192
197
  return /* @__PURE__ */ jsx("div", {
193
198
  role: "log",
194
199
  className: clsx("flex-1 overflow-y-auto [scrollbar-width:none]", className),
195
200
  style: { background: theme.surface },
201
+ onScroll,
196
202
  children: /* @__PURE__ */ jsxs("div", {
197
203
  className: "flex flex-col gap-4 p-7",
198
204
  children: [
@@ -210,10 +216,10 @@ function MessageList({ className }) {
210
216
  children: userTranscript ? userTranscript : /* @__PURE__ */ jsx(ThinkingDots, {})
211
217
  }),
212
218
  showThinking && /* @__PURE__ */ jsx(ThinkingDots, {}),
213
- /* @__PURE__ */ jsx("div", { ref: scrollRef })
219
+ /* @__PURE__ */ jsx("div", { ref: anchorRef })
214
220
  ]
215
221
  })
216
222
  });
217
- }
223
+ });
218
224
  //#endregion
219
225
  export { MessageList };
@@ -1,6 +1,6 @@
1
1
  import { useSessionCore, useSessionSelector, useTheme } from "../context.js";
2
2
  import { Button } from "./button.js";
3
- import "../_colors-DJUordGv.js";
3
+ import "../_colors-DYX7XRTr.js";
4
4
  import { t as AaiLogo } from "../aai-logo-B8lDmsut.js";
5
5
  import { t as Eyebrow } from "../eyebrow-C6ZFuiz6.js";
6
6
  import clsx from "clsx";
@@ -9,6 +9,6 @@
9
9
  *
10
10
  * @public
11
11
  */
12
- export declare function TextControls({ className }: {
12
+ export declare const TextControls: import("react").MemoExoticComponent<({ className, }: {
13
13
  className?: string | undefined;
14
- }): import("react").JSX.Element;
14
+ }) => import("react").JSX.Element>;
@@ -12,6 +12,10 @@ import type { ToolCallInfo } from "../types.ts";
12
12
  * While the tool call is pending a shimmer animation is shown. Once
13
13
  * complete, clicking the row expands the formatted JSON result.
14
14
  *
15
+ * Memoized: tool-call objects are referentially stable across session
16
+ * snapshots and rows are keyed on the stable `callId`, so a list update only
17
+ * re-renders the rows whose tool call actually changed.
18
+ *
15
19
  * @example
16
20
  * ```tsx
17
21
  * <ToolCallBlock toolCall={toolCall} />
@@ -22,7 +26,7 @@ import type { ToolCallInfo } from "../types.ts";
22
26
  *
23
27
  * @public
24
28
  */
25
- export declare function ToolCallBlock({ toolCall, className, }: {
29
+ export declare const ToolCallBlock: import("react").MemoExoticComponent<({ toolCall, className, }: {
26
30
  toolCall: ToolCallInfo;
27
31
  className?: string;
28
- }): ReactNode;
32
+ }) => ReactNode>;
@@ -1,3 +1,3 @@
1
1
  import "../context.js";
2
- import { t as ToolCallBlock } from "../tool-call-block-7f1GTG-P.js";
2
+ import { t as ToolCallBlock } from "../tool-call-block-DIxpG8GM.js";
3
3
  export { ToolCallBlock };
package/dist/context.d.ts CHANGED
@@ -5,17 +5,9 @@ export declare function SessionProvider({ value, children }: {
5
5
  value: SessionCore;
6
6
  children?: ReactNode;
7
7
  }): import("react").FunctionComponentElement<import("react").ProviderProps<SessionCore | null>>;
8
- export type Session = SessionSnapshot & {
9
- start(): void;
10
- cancel(): void;
11
- resetState(): void;
12
- reset(): void;
13
- disconnect(): void;
14
- toggle(): void;
15
- startRecording(): void;
16
- stopRecording(): void;
17
- sendAudioFile(file: Blob): Promise<void>;
18
- };
8
+ /** The session snapshot merged with the core's control methods. Method
9
+ * signatures come from {@link SessionCore} — one source of truth. */
10
+ export type Session = SessionSnapshot & Pick<SessionCore, "start" | "cancel" | "resetState" | "reset" | "disconnect" | "toggle" | "startRecording" | "stopRecording" | "sendAudioFile">;
19
11
  /**
20
12
  * Return the raw {@link SessionCore} from context without subscribing to
21
13
  * snapshot changes. Useful for accessing stable methods (`start`, `toggle`,
package/dist/context.js CHANGED
@@ -1,4 +1,5 @@
1
- import { createContext, createElement, useCallback, useContext, useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { createContext, createElement, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
2
+ import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector";
2
3
  //#region context.ts
3
4
  const DEFAULT_THEME = {
4
5
  bg: "#FBF8F2",
@@ -27,8 +28,9 @@ function useSessionCore() {
27
28
  }
28
29
  function useSession() {
29
30
  const core = useSessionCore();
30
- return {
31
- ...useSyncExternalStore(core.subscribe, core.getSnapshot),
31
+ const snapshot = useSyncExternalStore(core.subscribe, core.getSnapshot);
32
+ return useMemo(() => ({
33
+ ...snapshot,
32
34
  start: core.start,
33
35
  cancel: core.cancel,
34
36
  resetState: core.resetState,
@@ -38,7 +40,7 @@ function useSession() {
38
40
  startRecording: core.startRecording,
39
41
  stopRecording: core.stopRecording,
40
42
  sendAudioFile: core.sendAudioFile
41
- };
43
+ }), [snapshot, core]);
42
44
  }
43
45
  /**
44
46
  * Subscribe to a narrow slice of the session snapshot.
@@ -56,29 +58,14 @@ function useSession() {
56
58
  */
57
59
  function useSessionSelector(selector, isEqual = Object.is) {
58
60
  const core = useSessionCore();
59
- const selectorRef = useRef(selector);
60
- selectorRef.current = selector;
61
- const isEqualRef = useRef(isEqual);
62
- isEqualRef.current = isEqual;
63
- const cacheRef = useRef({ hasValue: false });
64
- const getSelection = useCallback(() => {
65
- const next = selectorRef.current(core.getSnapshot());
66
- const cache = cacheRef.current;
67
- if (cache.hasValue && isEqualRef.current(cache.value, next)) return cache.value;
68
- cacheRef.current = {
69
- hasValue: true,
70
- value: next
71
- };
72
- return next;
73
- }, [core]);
74
- return useSyncExternalStore(core.subscribe, getSelection);
61
+ return useSyncExternalStoreWithSelector(core.subscribe, core.getSnapshot, core.getSnapshot, selector, isEqual);
75
62
  }
76
63
  const ThemeCtx = createContext(DEFAULT_THEME);
77
64
  function ThemeProvider({ value, children }) {
78
- const merged = value ? {
65
+ const merged = useMemo(() => value ? {
79
66
  ...DEFAULT_THEME,
80
67
  ...value
81
- } : DEFAULT_THEME;
68
+ } : DEFAULT_THEME, [value]);
82
69
  usePageBackground(merged.bg);
83
70
  return createElement(ThemeCtx.Provider, { value: merged }, children);
84
71
  }