@agent-native/core 0.94.1 → 0.94.3

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 (141) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +14 -0
  3. package/corpus/core/docs/content/cloneable-saas.mdx +15 -15
  4. package/corpus/core/docs/content/creating-templates.mdx +1 -1
  5. package/corpus/core/docs/content/extensions.mdx +1 -1
  6. package/corpus/core/docs/content/local-file-mode.mdx +6 -6
  7. package/corpus/core/docs/content/locales/ar-SA/voice-input.mdx +2 -2
  8. package/corpus/core/docs/content/locales/de-DE/voice-input.mdx +2 -2
  9. package/corpus/core/docs/content/locales/es-ES/voice-input.mdx +2 -2
  10. package/corpus/core/docs/content/locales/fr-FR/voice-input.mdx +2 -2
  11. package/corpus/core/docs/content/locales/hi-IN/voice-input.mdx +2 -2
  12. package/corpus/core/docs/content/locales/ja-JP/voice-input.mdx +2 -2
  13. package/corpus/core/docs/content/locales/ko-KR/voice-input.mdx +2 -2
  14. package/corpus/core/docs/content/locales/pt-BR/voice-input.mdx +2 -2
  15. package/corpus/core/docs/content/locales/zh-CN/voice-input.mdx +2 -2
  16. package/corpus/core/docs/content/locales/zh-TW/voice-input.mdx +2 -2
  17. package/corpus/core/docs/content/sharing.mdx +4 -4
  18. package/corpus/core/docs/content/template-analytics.mdx +2 -2
  19. package/corpus/core/docs/content/template-calendar.mdx +4 -4
  20. package/corpus/core/docs/content/template-clips.mdx +2 -2
  21. package/corpus/core/docs/content/template-content.mdx +12 -11
  22. package/corpus/core/docs/content/template-mail.mdx +1 -1
  23. package/corpus/core/docs/content/template-slides.mdx +2 -2
  24. package/corpus/core/docs/content/voice-input.mdx +2 -2
  25. package/corpus/core/docs/content/workspace.mdx +1 -1
  26. package/corpus/core/package.json +1 -1
  27. package/corpus/core/src/a2a/artifact-response.ts +154 -1
  28. package/corpus/core/src/client/AssistantChat.tsx +30 -0
  29. package/corpus/core/src/client/MultiTabAssistantChat.tsx +19 -1
  30. package/corpus/core/src/client/agent-chat.ts +6 -0
  31. package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +72 -2
  32. package/corpus/core/src/client/composer/realtime-voice-audio-level.ts +64 -0
  33. package/corpus/core/src/client/composer/realtime-voice-transcript.ts +134 -0
  34. package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +220 -6
  35. package/corpus/core/src/client/use-chat-threads.ts +33 -9
  36. package/corpus/core/src/integrations/a2a-continuation-processor.ts +118 -31
  37. package/corpus/core/src/integrations/a2a-continuations-store.ts +165 -7
  38. package/corpus/core/src/integrations/adapters/slack.ts +77 -7
  39. package/corpus/core/src/integrations/types.ts +30 -0
  40. package/corpus/core/src/integrations/webhook-handler.ts +54 -18
  41. package/corpus/core/src/scripts/call-agent.ts +1 -0
  42. package/corpus/core/src/server/request-context.ts +2 -0
  43. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
  44. package/corpus/templates/clips/app/components/player/comments-panel.tsx +98 -1
  45. package/corpus/templates/clips/changelog/2026-07-10-comment-urls-open-as-clickable-links.md +6 -0
  46. package/corpus/templates/clips/changelog/2026-07-10-desktop-recording-uploads-now-show-a-brief-uploaded-state-wi.md +6 -0
  47. package/corpus/templates/clips/changelog/2026-07-10-desktop-recordings-now-retry-saving-their-locally-captured-t.md +6 -0
  48. package/corpus/templates/clips/changelog/2026-07-10-desktop-update-prompts-are-more-compact-and-easier-to-dismiss.md +6 -0
  49. package/corpus/templates/clips/changelog/2026-07-10-quiet-recordings-now-finish-saving-instead-of-getting-stuck.md +6 -0
  50. package/corpus/templates/clips/desktop/src/components/UpdateBanner.tsx +7 -4
  51. package/corpus/templates/clips/desktop/src/lib/recorder.ts +284 -85
  52. package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +148 -40
  53. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +13 -13
  54. package/corpus/templates/clips/desktop/src/styles.css +83 -13
  55. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +43 -28
  56. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +130 -65
  57. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +10 -4
  58. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +82 -1
  59. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +82 -1
  60. package/dist/a2a/artifact-response.d.ts.map +1 -1
  61. package/dist/a2a/artifact-response.js +127 -1
  62. package/dist/a2a/artifact-response.js.map +1 -1
  63. package/dist/client/AssistantChat.d.ts.map +1 -1
  64. package/dist/client/AssistantChat.js +20 -0
  65. package/dist/client/AssistantChat.js.map +1 -1
  66. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  67. package/dist/client/MultiTabAssistantChat.js +9 -0
  68. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  69. package/dist/client/agent-chat.d.ts +6 -0
  70. package/dist/client/agent-chat.d.ts.map +1 -1
  71. package/dist/client/agent-chat.js.map +1 -1
  72. package/dist/client/composer/RealtimeVoiceMode.d.ts +3 -1
  73. package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
  74. package/dist/client/composer/RealtimeVoiceMode.js +32 -3
  75. package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
  76. package/dist/client/composer/realtime-voice-audio-level.d.ts +14 -0
  77. package/dist/client/composer/realtime-voice-audio-level.d.ts.map +1 -0
  78. package/dist/client/composer/realtime-voice-audio-level.js +47 -0
  79. package/dist/client/composer/realtime-voice-audio-level.js.map +1 -0
  80. package/dist/client/composer/realtime-voice-transcript.d.ts +26 -0
  81. package/dist/client/composer/realtime-voice-transcript.d.ts.map +1 -0
  82. package/dist/client/composer/realtime-voice-transcript.js +103 -0
  83. package/dist/client/composer/realtime-voice-transcript.js.map +1 -0
  84. package/dist/client/composer/useRealtimeVoiceMode.d.ts +14 -0
  85. package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  86. package/dist/client/composer/useRealtimeVoiceMode.js +177 -6
  87. package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
  88. package/dist/client/use-chat-threads.d.ts.map +1 -1
  89. package/dist/client/use-chat-threads.js +26 -6
  90. package/dist/client/use-chat-threads.js.map +1 -1
  91. package/dist/collab/routes.d.ts +1 -1
  92. package/dist/integrations/a2a-continuation-processor.d.ts.map +1 -1
  93. package/dist/integrations/a2a-continuation-processor.js +76 -21
  94. package/dist/integrations/a2a-continuation-processor.js.map +1 -1
  95. package/dist/integrations/a2a-continuations-store.d.ts +4 -1
  96. package/dist/integrations/a2a-continuations-store.d.ts.map +1 -1
  97. package/dist/integrations/a2a-continuations-store.js +130 -6
  98. package/dist/integrations/a2a-continuations-store.js.map +1 -1
  99. package/dist/integrations/adapters/slack.d.ts.map +1 -1
  100. package/dist/integrations/adapters/slack.js +56 -7
  101. package/dist/integrations/adapters/slack.js.map +1 -1
  102. package/dist/integrations/types.d.ts +25 -0
  103. package/dist/integrations/types.d.ts.map +1 -1
  104. package/dist/integrations/types.js.map +1 -1
  105. package/dist/integrations/webhook-handler.js +54 -16
  106. package/dist/integrations/webhook-handler.js.map +1 -1
  107. package/dist/observability/routes.d.ts +1 -1
  108. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  109. package/dist/scripts/call-agent.d.ts.map +1 -1
  110. package/dist/scripts/call-agent.js +1 -0
  111. package/dist/scripts/call-agent.js.map +1 -1
  112. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  113. package/dist/server/request-context.d.ts +2 -0
  114. package/dist/server/request-context.d.ts.map +1 -1
  115. package/dist/server/request-context.js.map +1 -1
  116. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
  117. package/docs/content/cloneable-saas.mdx +15 -15
  118. package/docs/content/creating-templates.mdx +1 -1
  119. package/docs/content/extensions.mdx +1 -1
  120. package/docs/content/local-file-mode.mdx +6 -6
  121. package/docs/content/locales/ar-SA/voice-input.mdx +2 -2
  122. package/docs/content/locales/de-DE/voice-input.mdx +2 -2
  123. package/docs/content/locales/es-ES/voice-input.mdx +2 -2
  124. package/docs/content/locales/fr-FR/voice-input.mdx +2 -2
  125. package/docs/content/locales/hi-IN/voice-input.mdx +2 -2
  126. package/docs/content/locales/ja-JP/voice-input.mdx +2 -2
  127. package/docs/content/locales/ko-KR/voice-input.mdx +2 -2
  128. package/docs/content/locales/pt-BR/voice-input.mdx +2 -2
  129. package/docs/content/locales/zh-CN/voice-input.mdx +2 -2
  130. package/docs/content/locales/zh-TW/voice-input.mdx +2 -2
  131. package/docs/content/sharing.mdx +4 -4
  132. package/docs/content/template-analytics.mdx +2 -2
  133. package/docs/content/template-calendar.mdx +4 -4
  134. package/docs/content/template-clips.mdx +2 -2
  135. package/docs/content/template-content.mdx +12 -11
  136. package/docs/content/template-mail.mdx +1 -1
  137. package/docs/content/template-slides.mdx +2 -2
  138. package/docs/content/voice-input.mdx +2 -2
  139. package/docs/content/workspace.mdx +1 -1
  140. package/package.json +1 -1
  141. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
@@ -228,6 +228,132 @@ function contentDatabaseSubmissionArtifact(
228
228
  };
229
229
  }
230
230
 
231
+ function documentUrlForId(
232
+ parsed: Record<string, unknown>,
233
+ id: string,
234
+ additionalCandidates: Array<string | undefined> = [],
235
+ options: { requireContentOrigin?: boolean } = {},
236
+ ): string | undefined {
237
+ const candidates = [
238
+ stringValue(parsed.url),
239
+ stringValue(parsed.urlPath),
240
+ stringValue(parsed.deepLink),
241
+ stringValue(parsed.pageUrl),
242
+ stringValue(parsed.documentUrl),
243
+ ...additionalCandidates,
244
+ ].filter((value): value is string => !!value);
245
+
246
+ return candidates.find((candidate) => {
247
+ if (!artifactUrlReferencesId(candidate, "document", id)) return false;
248
+ return !options.requireContentOrigin || isContentDocumentUrl(candidate);
249
+ });
250
+ }
251
+
252
+ function isContentDocumentUrl(rawUrl: string): boolean {
253
+ try {
254
+ return new URL(rawUrl).origin === "https://content.agent-native.com";
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ function addDocumentReadArtifact(
261
+ documents: Map<string, CreatedDocumentArtifact>,
262
+ parsed: Record<string, unknown>,
263
+ options: {
264
+ allowWithoutUrl: boolean;
265
+ additionalUrlCandidates?: Array<string | undefined>;
266
+ requireContentOrigin?: boolean;
267
+ },
268
+ ): void {
269
+ const id = stringValue(parsed.documentId) ?? stringValue(parsed.id);
270
+ if (!id) return;
271
+
272
+ const url = documentUrlForId(parsed, id, options.additionalUrlCandidates, {
273
+ requireContentOrigin: options.requireContentOrigin,
274
+ });
275
+ if (!url && !options.allowWithoutUrl) return;
276
+
277
+ documents.set(id, {
278
+ id,
279
+ title: stringValue(parsed.title) ?? stringValue(parsed.name),
280
+ url,
281
+ });
282
+ }
283
+
284
+ function addContentDatabaseReadArtifacts(
285
+ documents: Map<string, CreatedDocumentArtifact>,
286
+ parsed: Record<string, unknown>,
287
+ ): void {
288
+ const resultUrls = [
289
+ stringValue(parsed.url),
290
+ stringValue(parsed.urlPath),
291
+ stringValue(parsed.deepLink),
292
+ ];
293
+ const database = asRecord(parsed.database);
294
+ if (database) {
295
+ addDocumentReadArtifact(documents, database, {
296
+ allowWithoutUrl: true,
297
+ requireContentOrigin: true,
298
+ additionalUrlCandidates: resultUrls,
299
+ });
300
+ } else {
301
+ // Unavailable database reads still return a documentId, but they do not
302
+ // prove that the page exists and must not authorize an artifact URL.
303
+ if (parsed.available !== false) {
304
+ addDocumentReadArtifact(documents, parsed, {
305
+ allowWithoutUrl: true,
306
+ requireContentOrigin: true,
307
+ });
308
+ }
309
+ }
310
+
311
+ if (!Array.isArray(parsed.items)) return;
312
+ for (const item of parsed.items) {
313
+ const itemRecord = asRecord(item);
314
+ const document = asRecord(itemRecord?.document);
315
+ if (!document) continue;
316
+ addDocumentReadArtifact(documents, document, {
317
+ allowWithoutUrl: true,
318
+ requireContentOrigin: true,
319
+ additionalUrlCandidates: [
320
+ stringValue(itemRecord?.url),
321
+ stringValue(itemRecord?.urlPath),
322
+ stringValue(itemRecord?.deepLink),
323
+ ],
324
+ });
325
+ }
326
+ }
327
+
328
+ function isGenericReadTool(tool: string): boolean {
329
+ return /^(?:find|get|list|query|read|search)-/i.test(tool);
330
+ }
331
+
332
+ function addGenericDocumentReadArtifact(
333
+ documents: Map<string, CreatedDocumentArtifact>,
334
+ parsed: Record<string, unknown>,
335
+ ): void {
336
+ // Unknown read actions are accepted only when their result pairs a document
337
+ // ID with a canonical page URL containing that exact ID. An ID by itself is
338
+ // insufficient, preserving the fabrication guard for unrelated actions.
339
+ addDocumentReadArtifact(documents, parsed, {
340
+ allowWithoutUrl: false,
341
+ requireContentOrigin: true,
342
+ });
343
+
344
+ const document = asRecord(parsed.document);
345
+ if (!document) return;
346
+ addDocumentReadArtifact(documents, document, {
347
+ allowWithoutUrl: false,
348
+ requireContentOrigin: true,
349
+ additionalUrlCandidates: [
350
+ stringValue(parsed.url),
351
+ stringValue(parsed.urlPath),
352
+ stringValue(parsed.deepLink),
353
+ ],
354
+ });
355
+ }
356
+
231
357
  function addImageArtifact(
232
358
  images: Map<string, CreatedImageArtifact>,
233
359
  parsed: Record<string, unknown>,
@@ -387,7 +513,6 @@ function collectArtifacts(results: A2AToolResultSummary[]): {
387
513
 
388
514
  if (
389
515
  toolResult.tool === "create-document" ||
390
- toolResult.tool === "get-document" ||
391
516
  toolResult.tool === "update-document"
392
517
  ) {
393
518
  const id = stringValue(parsed.id);
@@ -401,6 +526,34 @@ function collectArtifacts(results: A2AToolResultSummary[]): {
401
526
  continue;
402
527
  }
403
528
 
529
+ if (
530
+ toolResult.tool === "get-document" ||
531
+ toolResult.tool === "get-content-document"
532
+ ) {
533
+ const document = asRecord(parsed.document);
534
+ addDocumentReadArtifact(documents, document ?? parsed, {
535
+ allowWithoutUrl: true,
536
+ requireContentOrigin: true,
537
+ additionalUrlCandidates: document
538
+ ? [
539
+ stringValue(parsed.url),
540
+ stringValue(parsed.urlPath),
541
+ stringValue(parsed.deepLink),
542
+ ]
543
+ : [],
544
+ });
545
+ continue;
546
+ }
547
+
548
+ if (toolResult.tool === "get-content-database") {
549
+ addContentDatabaseReadArtifacts(documents, parsed);
550
+ continue;
551
+ }
552
+
553
+ if (isGenericReadTool(toolResult.tool)) {
554
+ addGenericDocumentReadArtifact(documents, parsed);
555
+ }
556
+
404
557
  if (
405
558
  toolResult.tool === "create-deck" ||
406
559
  toolResult.tool === "duplicate-deck"
@@ -146,6 +146,10 @@ import { AgentComposerFrame } from "./composer/AgentComposerFrame.js";
146
146
  import { TextAttachmentAdapter } from "./composer/attachment-accept.js";
147
147
  import { isPastedTextAttachmentName } from "./composer/pasted-text.js";
148
148
  import { PastedTextChip } from "./composer/PastedTextChip.js";
149
+ import {
150
+ appendRealtimeVoiceTranscriptToRepository,
151
+ realtimeVoiceTranscriptRegistry,
152
+ } from "./composer/realtime-voice-transcript.js";
149
153
  import {
150
154
  TiptapComposer,
151
155
  type ComposerSubmitIntent,
@@ -2280,6 +2284,32 @@ const AssistantChatInner = forwardRef<
2280
2284
  [threadRuntime],
2281
2285
  );
2282
2286
 
2287
+ const appendRealtimeVoiceTranscript = useCallback(
2288
+ (
2289
+ transcript: Parameters<typeof realtimeVoiceTranscriptRegistry.publish>[0],
2290
+ ) => {
2291
+ if (isRestoring || isRunning) return false;
2292
+ const result = appendRealtimeVoiceTranscriptToRepository(
2293
+ exportCleanThreadRepo(),
2294
+ transcript,
2295
+ );
2296
+ if (!result.appended) return true;
2297
+ threadRuntime.import(ensureMessageMetadata(result.repository));
2298
+ return true;
2299
+ },
2300
+ [exportCleanThreadRepo, isRestoring, isRunning, threadRuntime],
2301
+ );
2302
+
2303
+ useEffect(() => {
2304
+ const transcriptThreadId = threadId ?? tabId;
2305
+ if (!transcriptThreadId) return;
2306
+ return realtimeVoiceTranscriptRegistry.register({
2307
+ threadId: transcriptThreadId,
2308
+ active: isActiveComposer,
2309
+ append: appendRealtimeVoiceTranscript,
2310
+ });
2311
+ }, [appendRealtimeVoiceTranscript, isActiveComposer, tabId, threadId]);
2312
+
2283
2313
  const appendThreadMessage = useCallback(
2284
2314
  (message: Parameters<typeof threadRuntime.append>[0]) => {
2285
2315
  try {
@@ -2117,13 +2117,31 @@ export function MultiTabAssistantChat({
2117
2117
  useEffect(() => {
2118
2118
  const handleOpenThread = (event: Event) => {
2119
2119
  const detail = (event as CustomEvent).detail as
2120
- | { threadId?: unknown; newThread?: unknown; openRequestId?: unknown }
2120
+ | {
2121
+ threadId?: unknown;
2122
+ newThread?: unknown;
2123
+ onlyIfActiveThreadId?: unknown;
2124
+ openRequestId?: unknown;
2125
+ }
2121
2126
  | undefined;
2122
2127
  const threadId =
2123
2128
  typeof detail?.threadId === "string" ? detail.threadId : "";
2124
2129
  if (!detail || !threadId) return;
2125
2130
  if (!claimAgentChatOpenRequest(detail.openRequestId)) return;
2126
2131
 
2132
+ const onlyIfActiveThreadId =
2133
+ typeof detail.onlyIfActiveThreadId === "string"
2134
+ ? detail.onlyIfActiveThreadId.trim()
2135
+ : "";
2136
+ const activeThreadId = activeThreadIdRef.current;
2137
+ if (
2138
+ onlyIfActiveThreadId &&
2139
+ activeThreadId &&
2140
+ activeThreadId !== onlyIfActiveThreadId
2141
+ ) {
2142
+ return;
2143
+ }
2144
+
2127
2145
  if (detail?.newThread === true) {
2128
2146
  newThreadIds.current.add(threadId);
2129
2147
  void createThread(threadId).then((createdId) => {
@@ -138,6 +138,12 @@ export interface AgentChatContextState {
138
138
  export interface AgentChatOpenThreadRequest {
139
139
  threadId: string;
140
140
  newThread?: boolean;
141
+ /**
142
+ * Open only while this thread is still active (or no thread is active).
143
+ * This lets transient surfaces restore their own chat without stealing a
144
+ * thread the user selected in the meantime.
145
+ */
146
+ onlyIfActiveThreadId?: string;
141
147
  openRequestId?: string;
142
148
  }
143
149
 
@@ -6,7 +6,7 @@ import {
6
6
  IconPhoneOff,
7
7
  IconVolume,
8
8
  } from "@tabler/icons-react";
9
- import { useId, useState } from "react";
9
+ import { useEffect, useId, useState, useSyncExternalStore } from "react";
10
10
 
11
11
  import {
12
12
  Popover,
@@ -19,6 +19,10 @@ import {
19
19
  TooltipTrigger,
20
20
  } from "../components/ui/tooltip.js";
21
21
  import { cn } from "../utils.js";
22
+ import {
23
+ createRealtimeVoiceAudioLevelStore,
24
+ type RealtimeVoiceAudioLevelStore,
25
+ } from "./realtime-voice-audio-level.js";
22
26
 
23
27
  export type RealtimeVoiceModeState =
24
28
  | "connecting"
@@ -164,12 +168,55 @@ export interface RealtimeVoiceModeDockProps {
164
168
  state: RealtimeVoiceModeState;
165
169
  copy: RealtimeVoiceModeCopy;
166
170
  chatVisible: boolean;
171
+ audioLevels?: RealtimeVoiceAudioLevelStore;
167
172
  onToggleChat: () => void;
168
173
  onEndVoiceMode: () => void;
169
174
  errorMessage?: string | null;
170
175
  className?: string;
171
176
  }
172
177
 
178
+ const SILENT_AUDIO_LEVELS = createRealtimeVoiceAudioLevelStore();
179
+ const WAVEFORM_WEIGHTS = [0.55, 0.82, 1, 0.82, 0.55];
180
+ const AUDIO_ACTIVITY_THRESHOLD = 0.035;
181
+
182
+ function usePrefersReducedMotion(): boolean {
183
+ const [reduced, setReduced] = useState(false);
184
+ useEffect(() => {
185
+ if (typeof window === "undefined" || !window.matchMedia) return;
186
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
187
+ const update = () => setReduced(query.matches);
188
+ update();
189
+ query.addEventListener?.("change", update);
190
+ return () => query.removeEventListener?.("change", update);
191
+ }, []);
192
+ return reduced;
193
+ }
194
+
195
+ function VoiceWaveform({
196
+ level,
197
+ reducedMotion,
198
+ }: {
199
+ level: number;
200
+ reducedMotion: boolean;
201
+ }) {
202
+ const visibleLevel = reducedMotion ? 0.45 : level;
203
+ return (
204
+ <span
205
+ aria-hidden="true"
206
+ className="flex h-6 items-center justify-center gap-0.5"
207
+ data-realtime-voice-waveform="true"
208
+ >
209
+ {WAVEFORM_WEIGHTS.map((weight, index) => (
210
+ <span
211
+ key={index}
212
+ className="w-0.5 rounded-full bg-current transition-[height] duration-75 ease-out motion-reduce:transition-none"
213
+ style={{ height: `${4 + visibleLevel * 16 * weight}px` }}
214
+ />
215
+ ))}
216
+ </span>
217
+ );
218
+ }
219
+
173
220
  const ORB_STATE_CLASSES: Record<RealtimeVoiceModeState, string> = {
174
221
  connecting:
175
222
  "bg-secondary text-secondary-foreground ring-4 ring-secondary/40 hover:bg-secondary/80",
@@ -212,12 +259,26 @@ export function RealtimeVoiceModeDock({
212
259
  state,
213
260
  copy,
214
261
  chatVisible,
262
+ audioLevels = SILENT_AUDIO_LEVELS,
215
263
  onToggleChat,
216
264
  onEndVoiceMode,
217
265
  errorMessage,
218
266
  className,
219
267
  }: RealtimeVoiceModeDockProps) {
220
268
  const statusId = useId();
269
+ const levels = useSyncExternalStore(
270
+ audioLevels.subscribe,
271
+ audioLevels.getSnapshot,
272
+ audioLevels.getSnapshot,
273
+ );
274
+ const reducedMotion = usePrefersReducedMotion();
275
+ const activity =
276
+ levels.output > AUDIO_ACTIVITY_THRESHOLD
277
+ ? "assistant"
278
+ : levels.input > AUDIO_ACTIVITY_THRESHOLD
279
+ ? "user"
280
+ : "idle";
281
+ const activityLevel = activity === "assistant" ? levels.output : levels.input;
221
282
  const toggleLabel = chatVisible ? copy.hideChat : copy.showChat;
222
283
  const ending = state === "ending";
223
284
  const errorDetailVisible = state === "error" && Boolean(errorMessage);
@@ -228,7 +289,9 @@ export function RealtimeVoiceModeDock({
228
289
  "pointer-events-none fixed bottom-4 end-4 flex max-w-[calc(100vw-2rem)] flex-col items-end gap-2",
229
290
  className,
230
291
  )}
292
+ style={{ zIndex: 270 }}
231
293
  data-realtime-voice-state={state}
294
+ data-realtime-voice-activity={activity}
232
295
  >
233
296
  {errorDetailVisible ? (
234
297
  <div
@@ -283,7 +346,14 @@ export function RealtimeVoiceModeDock({
283
346
  ORB_STATE_CLASSES[state],
284
347
  )}
285
348
  >
286
- <VoiceStateIcon state={state} />
349
+ {activity === "idle" ? (
350
+ <VoiceStateIcon state={state} />
351
+ ) : (
352
+ <VoiceWaveform
353
+ level={activityLevel}
354
+ reducedMotion={reducedMotion}
355
+ />
356
+ )}
287
357
  </Button>
288
358
  </TooltipTrigger>
289
359
  <TooltipContent>{toggleLabel}</TooltipContent>
@@ -0,0 +1,64 @@
1
+ export interface RealtimeVoiceAudioLevels {
2
+ input: number;
3
+ output: number;
4
+ }
5
+
6
+ export interface RealtimeVoiceAudioLevelStore {
7
+ getSnapshot: () => RealtimeVoiceAudioLevels;
8
+ subscribe: (listener: () => void) => () => void;
9
+ set: (levels: RealtimeVoiceAudioLevels) => void;
10
+ reset: () => void;
11
+ }
12
+
13
+ const SILENT_LEVELS: RealtimeVoiceAudioLevels = { input: 0, output: 0 };
14
+
15
+ function clampLevel(value: number): number {
16
+ if (!Number.isFinite(value)) return 0;
17
+ return Math.max(0, Math.min(1, value));
18
+ }
19
+
20
+ export function normalizeRealtimeVoiceRms(samples: Uint8Array): number {
21
+ if (samples.length === 0) return 0;
22
+ let sumSquares = 0;
23
+ for (const sample of samples) {
24
+ const normalized = (sample - 128) / 128;
25
+ sumSquares += normalized * normalized;
26
+ }
27
+ const rms = Math.sqrt(sumSquares / samples.length);
28
+ return clampLevel(Math.max(0, rms - 0.012) * 3.2);
29
+ }
30
+
31
+ export function smoothRealtimeVoiceLevel(
32
+ previous: number,
33
+ next: number,
34
+ ): number {
35
+ const target = clampLevel(next);
36
+ const factor = target > previous ? 0.55 : 0.2;
37
+ return clampLevel(previous + (target - previous) * factor);
38
+ }
39
+
40
+ export function createRealtimeVoiceAudioLevelStore(): RealtimeVoiceAudioLevelStore {
41
+ let snapshot = SILENT_LEVELS;
42
+ const listeners = new Set<() => void>();
43
+
44
+ const set = (levels: RealtimeVoiceAudioLevels) => {
45
+ const next = {
46
+ input: clampLevel(levels.input),
47
+ output: clampLevel(levels.output),
48
+ };
49
+ if (next.input === snapshot.input && next.output === snapshot.output)
50
+ return;
51
+ snapshot = next;
52
+ for (const listener of listeners) listener();
53
+ };
54
+
55
+ return {
56
+ getSnapshot: () => snapshot,
57
+ subscribe(listener) {
58
+ listeners.add(listener);
59
+ return () => listeners.delete(listener);
60
+ },
61
+ set,
62
+ reset: () => set(SILENT_LEVELS),
63
+ };
64
+ }
@@ -0,0 +1,134 @@
1
+ export type RealtimeVoiceTranscriptRole = "user" | "assistant";
2
+
3
+ export interface RealtimeVoiceTranscriptMessage {
4
+ id: string;
5
+ threadId: string;
6
+ role: RealtimeVoiceTranscriptRole;
7
+ text: string;
8
+ createdAt: string;
9
+ }
10
+
11
+ export interface RealtimeVoiceTranscriptSink {
12
+ threadId: string;
13
+ active: boolean;
14
+ append: (message: RealtimeVoiceTranscriptMessage) => boolean;
15
+ }
16
+
17
+ export interface RealtimeVoiceTranscriptRegistry {
18
+ activeThreadId: () => string | undefined;
19
+ publish: (message: RealtimeVoiceTranscriptMessage) => void;
20
+ register: (sink: RealtimeVoiceTranscriptSink) => () => void;
21
+ pendingCount: () => number;
22
+ }
23
+
24
+ export function appendRealtimeVoiceTranscriptToRepository(
25
+ value: unknown,
26
+ transcript: RealtimeVoiceTranscriptMessage,
27
+ ): { appended: boolean; repository: Record<string, unknown> } {
28
+ const repository =
29
+ value && typeof value === "object" && !Array.isArray(value)
30
+ ? (value as Record<string, unknown>)
31
+ : {};
32
+ const messages = Array.isArray(repository.messages)
33
+ ? [...repository.messages]
34
+ : [];
35
+ const duplicate = messages.some((entry) => {
36
+ if (!entry || typeof entry !== "object") return false;
37
+ const record = entry as Record<string, unknown>;
38
+ const message =
39
+ record.message && typeof record.message === "object"
40
+ ? (record.message as Record<string, unknown>)
41
+ : record;
42
+ return message.id === transcript.id;
43
+ });
44
+ if (duplicate) return { appended: false, repository };
45
+
46
+ const parentId =
47
+ typeof repository.headId === "string" ? repository.headId : null;
48
+ const createdAt = new Date(transcript.createdAt);
49
+ const message = {
50
+ id: transcript.id,
51
+ role: transcript.role,
52
+ content: [{ type: "text", text: transcript.text }],
53
+ createdAt: Number.isNaN(createdAt.getTime()) ? new Date() : createdAt,
54
+ metadata: {
55
+ custom: {
56
+ source: "realtime-voice",
57
+ },
58
+ },
59
+ ...(transcript.role === "assistant"
60
+ ? { status: { type: "complete", reason: "stop" } }
61
+ : {}),
62
+ };
63
+
64
+ return {
65
+ appended: true,
66
+ repository: {
67
+ ...repository,
68
+ messages: [...messages, { message, parentId }],
69
+ headId: transcript.id,
70
+ },
71
+ };
72
+ }
73
+
74
+ export function createRealtimeVoiceTranscriptRegistry(): RealtimeVoiceTranscriptRegistry {
75
+ const sinks = new Map<
76
+ symbol,
77
+ RealtimeVoiceTranscriptSink & { order: number }
78
+ >();
79
+ const pending = new Map<string, RealtimeVoiceTranscriptMessage>();
80
+ const delivered = new Set<string>();
81
+ let order = 0;
82
+
83
+ const flush = () => {
84
+ for (const [id, message] of pending) {
85
+ let consumed = false;
86
+ for (const sink of sinks.values()) {
87
+ if (sink.threadId !== message.threadId) continue;
88
+ try {
89
+ consumed = sink.append(message) || consumed;
90
+ } catch {
91
+ // Keep the completed transcript queued until the chat can import it.
92
+ }
93
+ }
94
+ if (!consumed) continue;
95
+ pending.delete(id);
96
+ delivered.add(id);
97
+ if (delivered.size > 1_000) {
98
+ const oldest = delivered.values().next().value;
99
+ if (typeof oldest === "string") delivered.delete(oldest);
100
+ }
101
+ }
102
+ };
103
+
104
+ return {
105
+ activeThreadId() {
106
+ let active: (RealtimeVoiceTranscriptSink & { order: number }) | undefined;
107
+ for (const sink of sinks.values()) {
108
+ if (!sink.active || (active && sink.order <= active.order)) continue;
109
+ active = sink;
110
+ }
111
+ return active?.threadId;
112
+ },
113
+ publish(message) {
114
+ const id = message.id.trim();
115
+ const threadId = message.threadId.trim();
116
+ const text = message.text.trim();
117
+ if (!id || !threadId || !text || delivered.has(id) || pending.has(id)) {
118
+ return;
119
+ }
120
+ pending.set(id, { ...message, id, threadId, text });
121
+ flush();
122
+ },
123
+ register(sink) {
124
+ const token = Symbol("realtime-voice-transcript-sink");
125
+ sinks.set(token, { ...sink, order: ++order });
126
+ flush();
127
+ return () => sinks.delete(token);
128
+ },
129
+ pendingCount: () => pending.size,
130
+ };
131
+ }
132
+
133
+ export const realtimeVoiceTranscriptRegistry =
134
+ createRealtimeVoiceTranscriptRegistry();