@assistant-ui/react 0.15.32 → 0.15.34

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 (53) hide show
  1. package/dist/adapters/GeminiLiveVoiceAdapter.d.ts +26 -0
  2. package/dist/adapters/GeminiLiveVoiceAdapter.d.ts.map +1 -0
  3. package/dist/adapters/GeminiLiveVoiceAdapter.js +346 -0
  4. package/dist/adapters/GeminiLiveVoiceAdapter.js.map +1 -0
  5. package/dist/adapters/gemini-live/audio.d.ts +27 -0
  6. package/dist/adapters/gemini-live/audio.d.ts.map +1 -0
  7. package/dist/adapters/gemini-live/audio.js +291 -0
  8. package/dist/adapters/gemini-live/audio.js.map +1 -0
  9. package/dist/context/stores/ThreadViewport.js +1 -1
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/internal.js +1 -1
  14. package/dist/mcp-apps/McpAppRenderer.js +1 -1
  15. package/dist/mcp-apps/app-frame.js +1 -1
  16. package/dist/primitives/actionBar/ActionBarRefreshRun.d.ts +26 -0
  17. package/dist/primitives/actionBar/ActionBarRefreshRun.d.ts.map +1 -0
  18. package/dist/primitives/actionBar/ActionBarRefreshRun.js +26 -0
  19. package/dist/primitives/actionBar/ActionBarRefreshRun.js.map +1 -0
  20. package/dist/primitives/actionBar/ActionBarResume.d.ts +30 -0
  21. package/dist/primitives/actionBar/ActionBarResume.d.ts.map +1 -0
  22. package/dist/primitives/actionBar/ActionBarResume.js +30 -0
  23. package/dist/primitives/actionBar/ActionBarResume.js.map +1 -0
  24. package/dist/primitives/actionBar/ActionBarRoot.d.ts +4 -2
  25. package/dist/primitives/actionBar/ActionBarRoot.d.ts.map +1 -1
  26. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  27. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts.map +1 -1
  28. package/dist/primitives/actionBar/useActionBarFloatStatus.js +1 -1
  29. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  30. package/dist/primitives/actionBar.d.ts +2 -0
  31. package/dist/primitives/actionBar.d.ts.map +1 -1
  32. package/dist/primitives/actionBar.js +5 -1
  33. package/dist/primitives/actionBar.js.map +1 -1
  34. package/dist/primitives/error/ErrorRoot.d.ts +5 -0
  35. package/dist/primitives/error/ErrorRoot.d.ts.map +1 -1
  36. package/dist/primitives/error/ErrorRoot.js +30 -10
  37. package/dist/primitives/error/ErrorRoot.js.map +1 -1
  38. package/dist/primitives/message/MessagePartsGrouped.js +1 -1
  39. package/dist/sandbox-host/SandboxHost.js +1 -1
  40. package/dist/unstable/useComposerInputHistory.js +1 -1
  41. package/package.json +6 -6
  42. package/src/adapters/GeminiLiveVoiceAdapter.test.ts +992 -0
  43. package/src/adapters/GeminiLiveVoiceAdapter.ts +454 -0
  44. package/src/adapters/gemini-live/audio.test.ts +880 -0
  45. package/src/adapters/gemini-live/audio.ts +333 -0
  46. package/src/index.test.ts +34 -0
  47. package/src/index.ts +4 -0
  48. package/src/primitives/actionBar/ActionBarRefreshRun.ts +37 -0
  49. package/src/primitives/actionBar/ActionBarResume.ts +41 -0
  50. package/src/primitives/actionBar/ActionBarRoot.tsx +2 -1
  51. package/src/primitives/actionBar/useActionBarFloatStatus.ts +5 -1
  52. package/src/primitives/actionBar.ts +2 -0
  53. package/src/primitives/error/ErrorRoot.tsx +15 -1
@@ -0,0 +1,333 @@
1
+ import {
2
+ bytesToBase64,
3
+ raceWithAbortSignal,
4
+ } from "@assistant-ui/core/internal";
5
+
6
+ export const encodePcm16 = (samples: Float32Array): string => {
7
+ const bytes = new Uint8Array(samples.length * 2);
8
+ const view = new DataView(bytes.buffer);
9
+ for (let i = 0; i < samples.length; i++) {
10
+ const s = Math.max(-1, Math.min(1, samples[i]!));
11
+ view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
12
+ }
13
+ return bytesToBase64(bytes);
14
+ };
15
+
16
+ export const decodePcm16 = (base64: string): Float32Array<ArrayBuffer> => {
17
+ const binary = atob(base64);
18
+ const samples = new Float32Array(new ArrayBuffer((binary.length >> 1) * 4));
19
+ const view = new DataView(new ArrayBuffer(samples.length * 2));
20
+ for (let i = 0; i < samples.length * 2; i++)
21
+ view.setUint8(i, binary.charCodeAt(i));
22
+ for (let i = 0; i < samples.length; i++)
23
+ samples[i] = view.getInt16(i * 2, true) / 0x8000;
24
+ return samples;
25
+ };
26
+
27
+ export const readLevel = (analyser: AnalyserNode): number => {
28
+ const data = new Uint8Array(analyser.fftSize);
29
+ analyser.getByteTimeDomainData(data);
30
+ let sum = 0;
31
+ for (const v of data) {
32
+ const centered = (v - 128) / 128;
33
+ sum += centered * centered;
34
+ }
35
+ return Math.min(1, Math.sqrt(sum / data.length) * 2);
36
+ };
37
+
38
+ export type MicrophoneCapture = {
39
+ setMuted: (muted: boolean) => void;
40
+ level: () => number;
41
+ close: () => Promise<void>;
42
+ };
43
+
44
+ const createLowPass = (ratio: number) => {
45
+ const omega = (Math.PI * 0.9) / ratio;
46
+ const cosine = Math.cos(omega);
47
+ const sine = Math.sin(omega);
48
+ const sections = [0.5176380902, 0.7071067812, 1.9318516526].map((q) => {
49
+ const alpha = sine / (2 * q);
50
+ const a0 = 1 + alpha;
51
+ const b = (1 - cosine) / (2 * a0);
52
+ return { b, a1: (-2 * cosine) / a0, a2: (1 - alpha) / a0, z1: 0, z2: 0 };
53
+ });
54
+ return (samples: Float32Array) => {
55
+ for (let i = 0; i < samples.length; i++) {
56
+ let x = samples[i]!;
57
+ for (const s of sections) {
58
+ const y = s.b * x + s.z1;
59
+ s.z1 = 2 * s.b * x - s.a1 * y + s.z2;
60
+ s.z2 = s.b * x - s.a2 * y;
61
+ x = y;
62
+ }
63
+ samples[i] = x;
64
+ }
65
+ };
66
+ };
67
+
68
+ export const WORKLET_SOURCE = `
69
+ class PcmCapture extends AudioWorkletProcessor {
70
+ constructor(options) {
71
+ super();
72
+ this.ratio = sampleRate / options.processorOptions.targetRate;
73
+ this.chunk = Math.round(options.processorOptions.targetRate / 50);
74
+ this.position = 0;
75
+ this.last = 0;
76
+ this.pending = [];
77
+ this.sections = [];
78
+ if (this.ratio > 1) {
79
+ const omega = (Math.PI * 0.9) / this.ratio;
80
+ const cosine = Math.cos(omega);
81
+ const sine = Math.sin(omega);
82
+ for (const q of [0.5176380902, 0.7071067812, 1.9318516526]) {
83
+ const alpha = sine / (2 * q);
84
+ const a0 = 1 + alpha;
85
+ const b = (1 - cosine) / (2 * a0);
86
+ this.sections.push({ b, a1: (-2 * cosine) / a0, a2: (1 - alpha) / a0, z1: 0, z2: 0 });
87
+ }
88
+ }
89
+ }
90
+ process(inputs) {
91
+ const channels = inputs[0];
92
+ if (!channels || channels.length === 0 || channels[0].length === 0) return true;
93
+ const input = channels[0].slice();
94
+ for (let c = 1; c < channels.length; c++)
95
+ for (let i = 0; i < input.length; i++) input[i] += channels[c][i];
96
+ if (channels.length > 1)
97
+ for (let i = 0; i < input.length; i++) input[i] /= channels.length;
98
+ for (let i = 0; i < input.length; i++) {
99
+ let x = input[i];
100
+ for (const s of this.sections) {
101
+ const y = s.b * x + s.z1;
102
+ s.z1 = 2 * s.b * x - s.a1 * y + s.z2;
103
+ s.z2 = s.b * x - s.a2 * y;
104
+ x = y;
105
+ }
106
+ input[i] = x;
107
+ }
108
+ while (this.position < input.length - 1) {
109
+ const index = Math.floor(this.position);
110
+ const prev = index < 0 ? this.last : input[index];
111
+ const next = input[index + 1];
112
+ this.pending.push(prev + (next - prev) * (this.position - index));
113
+ this.position += this.ratio;
114
+ }
115
+ this.position -= input.length;
116
+ this.last = input[input.length - 1];
117
+ if (this.pending.length >= this.chunk) {
118
+ this.port.postMessage(Float32Array.from(this.pending));
119
+ this.pending = [];
120
+ }
121
+ return true;
122
+ }
123
+ }
124
+ registerProcessor("pcm-capture", PcmCapture);
125
+ `;
126
+
127
+ export const createResampler = (ratio: number) => {
128
+ let position = 0;
129
+ let last = 0;
130
+ const lowPass = ratio > 1 ? createLowPass(ratio) : undefined;
131
+ return (input: Float32Array): Float32Array => {
132
+ if (input.length === 0) return new Float32Array(0);
133
+ const samples = lowPass ? input.slice() : input;
134
+ lowPass?.(samples);
135
+ const out: number[] = [];
136
+ while (position < samples.length - 1) {
137
+ const index = Math.floor(position);
138
+ const prev = index < 0 ? last : samples[index]!;
139
+ const next = samples[index + 1]!;
140
+ out.push(prev + (next - prev) * (position - index));
141
+ position += ratio;
142
+ }
143
+ position -= samples.length;
144
+ last = samples[samples.length - 1]!;
145
+ return Float32Array.from(out);
146
+ };
147
+ };
148
+
149
+ export const openMicrophone = async (options: {
150
+ sampleRate: number;
151
+ onChunk: (base64: string) => void;
152
+ signal?: AbortSignal;
153
+ }): Promise<MicrophoneCapture> => {
154
+ let stream: MediaStream | undefined;
155
+ const stopTracks = () => {
156
+ stream?.getTracks().forEach((track) => track.stop());
157
+ stream = undefined;
158
+ };
159
+ const throwIfAborted = () => {
160
+ if (options.signal?.aborted) throw options.signal.reason;
161
+ };
162
+ let context: AudioContext | undefined;
163
+ try {
164
+ const openedStream = await raceWithAbortSignal(options.signal, async () => {
165
+ const acquired = await navigator.mediaDevices.getUserMedia({
166
+ audio: true,
167
+ });
168
+ stream = acquired;
169
+ if (options.signal?.aborted) {
170
+ stopTracks();
171
+ throw options.signal.reason;
172
+ }
173
+ return acquired;
174
+ });
175
+ throwIfAborted();
176
+ context = new AudioContext();
177
+ if (context.state === "suspended")
178
+ await raceWithAbortSignal(options.signal, () => context!.resume());
179
+ throwIfAborted();
180
+ const source = context.createMediaStreamSource(openedStream);
181
+ const analyser = context.createAnalyser();
182
+ analyser.fftSize = 256;
183
+ source.connect(analyser);
184
+
185
+ let muted = false;
186
+ let closed = false;
187
+ let closing: Promise<void> | undefined;
188
+ const push = (samples: Float32Array) => {
189
+ if (!muted && !closed && samples.length)
190
+ options.onChunk(encodePcm16(samples));
191
+ };
192
+
193
+ let node: AudioNode | undefined;
194
+ let detach = () => {};
195
+ if (context.audioWorklet) {
196
+ const url = URL.createObjectURL(
197
+ new Blob([WORKLET_SOURCE], { type: "text/javascript" }),
198
+ );
199
+ try {
200
+ await raceWithAbortSignal(options.signal, () =>
201
+ context!.audioWorklet.addModule(url),
202
+ );
203
+ throwIfAborted();
204
+ const worklet = new AudioWorkletNode(context, "pcm-capture", {
205
+ processorOptions: { targetRate: options.sampleRate },
206
+ });
207
+ worklet.port.onmessage = (event) => push(event.data as Float32Array);
208
+ detach = () => {
209
+ worklet.port.onmessage = null;
210
+ };
211
+ node = worklet;
212
+ } catch {
213
+ // A CSP can block blob worklets even when AudioWorklet is available.
214
+ throwIfAborted();
215
+ } finally {
216
+ URL.revokeObjectURL(url);
217
+ }
218
+ }
219
+ if (!node) {
220
+ const resample = createResampler(context.sampleRate / options.sampleRate);
221
+ const processor = context.createScriptProcessor(1024, 1, 1);
222
+ processor.onaudioprocess = (event) =>
223
+ push(resample(event.inputBuffer.getChannelData(0)));
224
+ detach = () => {
225
+ processor.onaudioprocess = null;
226
+ };
227
+ node = processor;
228
+ }
229
+ analyser.connect(node);
230
+ node.connect(context.destination);
231
+ const opened = context;
232
+
233
+ return {
234
+ setMuted: (value) => {
235
+ muted = value;
236
+ openedStream.getAudioTracks().forEach((track) => {
237
+ track.enabled = !value;
238
+ });
239
+ },
240
+ level: () => readLevel(analyser),
241
+ close: () => {
242
+ if (closing) return closing;
243
+ closed = true;
244
+ detach();
245
+ node.disconnect();
246
+ source.disconnect();
247
+ stopTracks();
248
+ return (closing = opened.close());
249
+ },
250
+ };
251
+ } catch (error) {
252
+ stopTracks();
253
+ await context?.close().catch(() => {});
254
+ throw error;
255
+ }
256
+ };
257
+
258
+ export type PcmPlayer = {
259
+ enqueue: (base64: string) => void;
260
+ clear: () => void;
261
+ isPlaying: () => boolean;
262
+ level: () => number;
263
+ close: () => Promise<void>;
264
+ };
265
+
266
+ const IDLE_GRACE_MS = 100;
267
+
268
+ export const createPcmPlayer = (options: {
269
+ sampleRate: number;
270
+ onIdle?: () => void;
271
+ }): PcmPlayer => {
272
+ const context = new AudioContext({ sampleRate: options.sampleRate });
273
+ const analyser = context.createAnalyser();
274
+ analyser.fftSize = 256;
275
+ analyser.connect(context.destination);
276
+ const sources = new Set<AudioBufferSourceNode>();
277
+ let cursor = 0;
278
+ let closing: Promise<void> | undefined;
279
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
280
+ const scheduleIdle = () => {
281
+ clearTimeout(idleTimer);
282
+ idleTimer = setTimeout(() => {
283
+ if (sources.size === 0) options.onIdle?.();
284
+ }, IDLE_GRACE_MS);
285
+ };
286
+
287
+ return {
288
+ enqueue: (base64) => {
289
+ const samples = decodePcm16(base64);
290
+ if (samples.length === 0) return;
291
+ const buffer = context.createBuffer(
292
+ 1,
293
+ samples.length,
294
+ options.sampleRate,
295
+ );
296
+ buffer.copyToChannel(samples, 0);
297
+ const source = context.createBufferSource();
298
+ source.buffer = buffer;
299
+ source.connect(analyser);
300
+ const start = Math.max(context.currentTime, cursor);
301
+ source.start(start);
302
+ cursor = start + buffer.duration;
303
+ sources.add(source);
304
+ source.onended = () => {
305
+ sources.delete(source);
306
+ if (sources.size === 0) scheduleIdle();
307
+ };
308
+ clearTimeout(idleTimer);
309
+ if (context.state === "suspended") void context.resume().catch(() => {});
310
+ },
311
+ clear: () => {
312
+ clearTimeout(idleTimer);
313
+ for (const source of sources) {
314
+ source.onended = null;
315
+ source.stop();
316
+ }
317
+ sources.clear();
318
+ cursor = 0;
319
+ },
320
+ isPlaying: () => sources.size > 0,
321
+ level: () => readLevel(analyser),
322
+ close: () => {
323
+ if (closing) return closing;
324
+ clearTimeout(idleTimer);
325
+ for (const source of sources) {
326
+ source.onended = null;
327
+ source.stop();
328
+ }
329
+ sources.clear();
330
+ return (closing = context.close());
331
+ },
332
+ };
333
+ };
@@ -0,0 +1,34 @@
1
+ /// <reference types="node" />
2
+ import { spawnSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { expect, it } from "vitest";
5
+
6
+ const PACKAGE_DIR = fileURLToPath(new URL("..", import.meta.url));
7
+
8
+ // The built entry imports React through @assistant-ui/tap/react-shim. Vite
9
+ // dev pre-bundles that shim with esbuild, whose CommonJS interop hands it
10
+ // React's default export only; the loader below recreates that for the shim.
11
+ const script = `
12
+ import { registerHooks } from "node:module";
13
+ registerHooks({
14
+ resolve(specifier, context, nextResolve) {
15
+ const resolved = nextResolve(specifier, context);
16
+ if (specifier !== "react" || !context.parentURL?.endsWith("/react-shim/index.js")) return resolved;
17
+ const source = "import React from " + JSON.stringify(resolved.url) + "; export default React;";
18
+ return { url: "data:text/javascript," + encodeURIComponent(source), shortCircuit: true };
19
+ },
20
+ });
21
+ const entry = await import("@assistant-ui/react");
22
+ console.log(typeof entry.ComposerPrimitive.Root, typeof entry.CloudRendererHost);
23
+ `;
24
+
25
+ it("loads the public entry when the react-shim only sees React's default export", () => {
26
+ const result = spawnSync(
27
+ process.execPath,
28
+ [...process.execArgv, "--input-type=module", "--eval", script],
29
+ { cwd: PACKAGE_DIR, encoding: "utf8" },
30
+ );
31
+
32
+ expect(result.status, result.stderr).toBe(0);
33
+ expect(result.stdout.trim()).toBe("object function");
34
+ });
package/src/index.ts CHANGED
@@ -69,6 +69,10 @@ export {
69
69
  OpenAIRealtimeVoiceAdapter,
70
70
  type OpenAIRealtimeVoiceAdapterOptions,
71
71
  } from "./adapters/OpenAIRealtimeVoiceAdapter";
72
+ export {
73
+ GeminiLiveVoiceAdapter,
74
+ type GeminiLiveVoiceAdapterOptions,
75
+ } from "./adapters/GeminiLiveVoiceAdapter";
72
76
  export type {
73
77
  VoiceSessionControls,
74
78
  VoiceSessionHelpers,
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import {
4
+ type ActionButtonElement,
5
+ type ActionButtonProps,
6
+ createActionButton,
7
+ } from "../../utils/createActionButton";
8
+ import { useActionBarRefreshRun as useActionBarRefreshRunBehavior } from "@assistant-ui/core/react";
9
+
10
+ const useActionBarRefreshRun = () => {
11
+ const { disabled, refresh } = useActionBarRefreshRunBehavior();
12
+ if (disabled) return null;
13
+ return refresh;
14
+ };
15
+
16
+ export namespace ActionBarPrimitiveRefreshRun {
17
+ export type Element = ActionButtonElement;
18
+ export type Props = ActionButtonProps<typeof useActionBarRefreshRun>;
19
+ }
20
+
21
+ /**
22
+ * A button that re-reads an assistant message whose run another tab or
23
+ * session is still writing, so the thread shows its latest stored state
24
+ * without a page reload. The thread also re-reads it on its own around the
25
+ * moment the run would cross `staleAfter`. It renders disabled unless
26
+ * Assistant Cloud reported the run in progress elsewhere, and while a
27
+ * refresh is in flight.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * <ActionBarPrimitive.RefreshRun>Refresh</ActionBarPrimitive.RefreshRun>
32
+ * ```
33
+ */
34
+ export const ActionBarPrimitiveRefreshRun = createActionButton(
35
+ "ActionBarPrimitive.RefreshRun",
36
+ useActionBarRefreshRun,
37
+ );
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import {
4
+ type ActionButtonElement,
5
+ type ActionButtonProps,
6
+ createActionButton,
7
+ } from "../../utils/createActionButton";
8
+ import { useActionBarResume as useActionBarResumeBehavior } from "@assistant-ui/core/react";
9
+
10
+ const useActionBarResume = () => {
11
+ const { disabled, resume } = useActionBarResumeBehavior();
12
+ if (disabled) return null;
13
+ return resume;
14
+ };
15
+
16
+ export namespace ActionBarPrimitiveResume {
17
+ export type Element = ActionButtonElement;
18
+ export type Props = ActionButtonProps<typeof useActionBarResume>;
19
+ }
20
+
21
+ /**
22
+ * A button that answers again in place of an assistant message whose run
23
+ * stopped before it finished (its tab closed or lost its connection). Like
24
+ * Regenerate, it starts a new branch beside the interrupted reply, which
25
+ * stays as it is. It renders disabled unless the message is the last one on
26
+ * its branch and its run went `staleAfter` without a heartbeat.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * <MessagePrimitive.Error>
31
+ * <ErrorPrimitive.Root>
32
+ * <ErrorPrimitive.Message />
33
+ * <ActionBarPrimitive.Resume>Resume</ActionBarPrimitive.Resume>
34
+ * </ErrorPrimitive.Root>
35
+ * </MessagePrimitive.Error>
36
+ * ```
37
+ */
38
+ export const ActionBarPrimitiveResume = createActionButton(
39
+ "ActionBarPrimitive.Resume",
40
+ useActionBarResume,
41
+ );
@@ -21,7 +21,8 @@ export namespace ActionBarPrimitiveRoot {
21
21
  export type Element = ComponentRef<typeof Primitive.div>;
22
22
  export type Props = PrimitiveDivProps & {
23
23
  /**
24
- * Whether to hide the action bar when the thread is running.
24
+ * Whether to hide the action bar when the thread is running, or while
25
+ * another tab or session is still writing this message.
25
26
  * @default false
26
27
  */
27
28
  hideWhenRunning?: boolean | undefined;
@@ -24,7 +24,11 @@ export const useActionBarFloatStatus = ({
24
24
  forceVisible,
25
25
  }: UseActionBarFloatStatusProps) => {
26
26
  return useAuiState((s) => {
27
- if (hideWhenRunning && s.thread.isRunning) return HideAndFloatStatus.Hidden;
27
+ if (
28
+ hideWhenRunning &&
29
+ (s.thread.isRunning || s.message.runState === "in-progress")
30
+ )
31
+ return HideAndFloatStatus.Hidden;
28
32
 
29
33
  const autohideEnabled =
30
34
  autohide === "always" || (autohide === "not-last" && !s.message.isLast);
@@ -1,6 +1,8 @@
1
1
  export { ActionBarPrimitiveRoot as Root } from "./actionBar/ActionBarRoot";
2
2
  export { ActionBarPrimitiveCopy as Copy } from "./actionBar/ActionBarCopy";
3
3
  export { ActionBarPrimitiveReload as Reload } from "./actionBar/ActionBarReload";
4
+ export { ActionBarPrimitiveResume as Resume } from "./actionBar/ActionBarResume";
5
+ export { ActionBarPrimitiveRefreshRun as RefreshRun } from "./actionBar/ActionBarRefreshRun";
4
6
  export { ActionBarPrimitiveEdit as Edit } from "./actionBar/ActionBarEdit";
5
7
  export { ActionBarPrimitiveSpeak as Speak } from "./actionBar/ActionBarSpeak";
6
8
  export { ActionBarPrimitiveStopSpeaking as StopSpeaking } from "./actionBar/ActionBarStopSpeaking";
@@ -6,17 +6,31 @@ import {
6
6
  forwardRef,
7
7
  type ComponentPropsWithoutRef,
8
8
  } from "react";
9
+ import { useAuiState } from "@assistant-ui/store";
9
10
 
10
11
  export namespace ErrorPrimitiveRoot {
11
12
  export type Element = ComponentRef<typeof Primitive.div>;
12
13
  export type Props = ComponentPropsWithoutRef<typeof Primitive.div>;
13
14
  }
14
15
 
16
+ /**
17
+ * The container of a message's error. It carries `data-run-state` (`stale` or
18
+ * `in-progress`) when the error stands for a run another tab or session
19
+ * drove, and announces an in-progress run as a status instead of an alert.
20
+ */
15
21
  export const ErrorPrimitiveRoot = forwardRef<
16
22
  ErrorPrimitiveRoot.Element,
17
23
  ErrorPrimitiveRoot.Props
18
24
  >((props, forwardRef) => {
19
- return <Primitive.div role="alert" {...props} ref={forwardRef} />;
25
+ const runState = useAuiState((s) => s.optional.message?.runState);
26
+ return (
27
+ <Primitive.div
28
+ role={runState === "in-progress" ? "status" : "alert"}
29
+ {...(runState ? { "data-run-state": runState } : undefined)}
30
+ {...props}
31
+ ref={forwardRef}
32
+ />
33
+ );
20
34
  });
21
35
 
22
36
  ErrorPrimitiveRoot.displayName = "ErrorPrimitive.Root";