@agent-native/core 0.94.1 → 0.94.2

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 (110) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +6 -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/client/AssistantChat.tsx +30 -0
  28. package/corpus/core/src/client/MultiTabAssistantChat.tsx +19 -1
  29. package/corpus/core/src/client/agent-chat.ts +6 -0
  30. package/corpus/core/src/client/composer/RealtimeVoiceMode.tsx +72 -2
  31. package/corpus/core/src/client/composer/realtime-voice-audio-level.ts +64 -0
  32. package/corpus/core/src/client/composer/realtime-voice-transcript.ts +134 -0
  33. package/corpus/core/src/client/composer/useRealtimeVoiceMode.tsx +220 -6
  34. package/corpus/core/src/client/use-chat-threads.ts +33 -9
  35. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
  36. package/corpus/templates/clips/app/components/player/comments-panel.tsx +98 -1
  37. package/corpus/templates/clips/changelog/2026-07-10-comment-urls-open-as-clickable-links.md +6 -0
  38. package/corpus/templates/clips/changelog/2026-07-10-desktop-recording-uploads-now-show-a-brief-uploaded-state-wi.md +6 -0
  39. package/corpus/templates/clips/changelog/2026-07-10-desktop-recordings-now-retry-saving-their-locally-captured-t.md +6 -0
  40. package/corpus/templates/clips/changelog/2026-07-10-desktop-update-prompts-are-more-compact-and-easier-to-dismiss.md +6 -0
  41. package/corpus/templates/clips/changelog/2026-07-10-quiet-recordings-now-finish-saving-instead-of-getting-stuck.md +6 -0
  42. package/corpus/templates/clips/desktop/src/components/UpdateBanner.tsx +7 -4
  43. package/corpus/templates/clips/desktop/src/lib/recorder.ts +284 -85
  44. package/corpus/templates/clips/desktop/src/overlays/finalizing.tsx +148 -40
  45. package/corpus/templates/clips/desktop/src/overlays/meeting-notification.tsx +13 -13
  46. package/corpus/templates/clips/desktop/src/styles.css +83 -13
  47. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +43 -28
  48. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +130 -65
  49. package/corpus/templates/clips/desktop/src-tauri/src/notifications.rs +10 -4
  50. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +82 -1
  51. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +82 -1
  52. package/dist/client/AssistantChat.d.ts.map +1 -1
  53. package/dist/client/AssistantChat.js +20 -0
  54. package/dist/client/AssistantChat.js.map +1 -1
  55. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  56. package/dist/client/MultiTabAssistantChat.js +9 -0
  57. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  58. package/dist/client/agent-chat.d.ts +6 -0
  59. package/dist/client/agent-chat.d.ts.map +1 -1
  60. package/dist/client/agent-chat.js.map +1 -1
  61. package/dist/client/composer/RealtimeVoiceMode.d.ts +3 -1
  62. package/dist/client/composer/RealtimeVoiceMode.d.ts.map +1 -1
  63. package/dist/client/composer/RealtimeVoiceMode.js +32 -3
  64. package/dist/client/composer/RealtimeVoiceMode.js.map +1 -1
  65. package/dist/client/composer/realtime-voice-audio-level.d.ts +14 -0
  66. package/dist/client/composer/realtime-voice-audio-level.d.ts.map +1 -0
  67. package/dist/client/composer/realtime-voice-audio-level.js +47 -0
  68. package/dist/client/composer/realtime-voice-audio-level.js.map +1 -0
  69. package/dist/client/composer/realtime-voice-transcript.d.ts +26 -0
  70. package/dist/client/composer/realtime-voice-transcript.d.ts.map +1 -0
  71. package/dist/client/composer/realtime-voice-transcript.js +103 -0
  72. package/dist/client/composer/realtime-voice-transcript.js.map +1 -0
  73. package/dist/client/composer/useRealtimeVoiceMode.d.ts +14 -0
  74. package/dist/client/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  75. package/dist/client/composer/useRealtimeVoiceMode.js +177 -6
  76. package/dist/client/composer/useRealtimeVoiceMode.js.map +1 -1
  77. package/dist/client/use-chat-threads.d.ts.map +1 -1
  78. package/dist/client/use-chat-threads.js +26 -6
  79. package/dist/client/use-chat-threads.js.map +1 -1
  80. package/dist/collab/routes.d.ts +1 -1
  81. package/dist/observability/routes.d.ts +1 -1
  82. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  83. package/dist/resources/handlers.d.ts +2 -2
  84. package/dist/server/transcribe-voice.d.ts +1 -1
  85. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
  86. package/docs/content/cloneable-saas.mdx +15 -15
  87. package/docs/content/creating-templates.mdx +1 -1
  88. package/docs/content/extensions.mdx +1 -1
  89. package/docs/content/local-file-mode.mdx +6 -6
  90. package/docs/content/locales/ar-SA/voice-input.mdx +2 -2
  91. package/docs/content/locales/de-DE/voice-input.mdx +2 -2
  92. package/docs/content/locales/es-ES/voice-input.mdx +2 -2
  93. package/docs/content/locales/fr-FR/voice-input.mdx +2 -2
  94. package/docs/content/locales/hi-IN/voice-input.mdx +2 -2
  95. package/docs/content/locales/ja-JP/voice-input.mdx +2 -2
  96. package/docs/content/locales/ko-KR/voice-input.mdx +2 -2
  97. package/docs/content/locales/pt-BR/voice-input.mdx +2 -2
  98. package/docs/content/locales/zh-CN/voice-input.mdx +2 -2
  99. package/docs/content/locales/zh-TW/voice-input.mdx +2 -2
  100. package/docs/content/sharing.mdx +4 -4
  101. package/docs/content/template-analytics.mdx +2 -2
  102. package/docs/content/template-calendar.mdx +4 -4
  103. package/docs/content/template-clips.mdx +2 -2
  104. package/docs/content/template-content.mdx +12 -11
  105. package/docs/content/template-mail.mdx +1 -1
  106. package/docs/content/template-slides.mdx +2 -2
  107. package/docs/content/voice-input.mdx +2 -2
  108. package/docs/content/workspace.mdx +1 -1
  109. package/package.json +1 -1
  110. package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +11 -5
@@ -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();
@@ -10,6 +10,7 @@ import {
10
10
  } from "react";
11
11
  import { createPortal } from "react-dom";
12
12
 
13
+ import { requestAgentChatThreadOpen } from "../agent-chat.js";
13
14
  import {
14
15
  SIDEBAR_STATE_CHANGE_EVENT,
15
16
  type AgentSidebarStateChangeDetail,
@@ -18,6 +19,13 @@ import { agentNativePath } from "../api-path.js";
18
19
  import { readClientAppState, setClientAppState } from "../application-state.js";
19
20
  import { getBrowserTabId } from "../browser-tab-id.js";
20
21
  import { useT } from "../i18n.js";
22
+ import {
23
+ createRealtimeVoiceAudioLevelStore,
24
+ normalizeRealtimeVoiceRms,
25
+ smoothRealtimeVoiceLevel,
26
+ type RealtimeVoiceAudioLevelStore,
27
+ } from "./realtime-voice-audio-level.js";
28
+ import { realtimeVoiceTranscriptRegistry } from "./realtime-voice-transcript.js";
21
29
  import {
22
30
  RealtimeVoiceModeDock,
23
31
  type RealtimeVoiceModeCopy,
@@ -43,11 +51,54 @@ export interface RealtimeVoiceModeApi {
43
51
  active: boolean;
44
52
  errorMessage: string | null;
45
53
  chatVisible: boolean;
54
+ audioLevels: RealtimeVoiceAudioLevelStore;
46
55
  start: () => Promise<void>;
47
56
  end: () => void;
48
57
  toggleChat: () => void;
49
58
  }
50
59
 
60
+ export interface CompletedRealtimeVoiceTranscript {
61
+ role: "user" | "assistant";
62
+ text: string;
63
+ providerId?: string;
64
+ }
65
+
66
+ /**
67
+ * Voice mode owns the chat only temporarily. Restore the captured transcript
68
+ * when it is still the user's active thread (or the chat has no active thread)
69
+ * but never pull them back after they deliberately selected another thread.
70
+ */
71
+ export function shouldRestoreRealtimeVoiceTranscriptThread(
72
+ transcriptThreadId: string | undefined,
73
+ activeThreadId: string | undefined,
74
+ ): transcriptThreadId is string {
75
+ return Boolean(
76
+ transcriptThreadId &&
77
+ (!activeThreadId || activeThreadId === transcriptThreadId),
78
+ );
79
+ }
80
+
81
+ export function extractCompletedRealtimeVoiceTranscript(
82
+ event: RealtimeServerEvent,
83
+ ): CompletedRealtimeVoiceTranscript | null {
84
+ const userCompleted =
85
+ event.type === "conversation.item.input_audio_transcription.completed";
86
+ const assistantCompleted =
87
+ event.type === "response.output_audio_transcript.done";
88
+ if (!userCompleted && !assistantCompleted) return null;
89
+ const text =
90
+ typeof event.transcript === "string" ? event.transcript.trim() : "";
91
+ if (!text) return null;
92
+ const providerId = [event.item_id, event.response_id, event.event_id].find(
93
+ (value): value is string => typeof value === "string" && value.length > 0,
94
+ );
95
+ return {
96
+ role: userCompleted ? "user" : "assistant",
97
+ text,
98
+ ...(providerId ? { providerId } : {}),
99
+ };
100
+ }
101
+
51
102
  export interface RealtimeVoiceModeProviderProps {
52
103
  children: ReactNode;
53
104
  browserTabId?: string;
@@ -232,17 +283,29 @@ function useRealtimeVoiceModeController(
232
283
  const [state, setState] = useState<"idle" | RealtimeVoiceModeState>("idle");
233
284
  const [error, setError] = useState<string | null>(null);
234
285
  const [chatVisible, setChatVisible] = useState(false);
286
+ const [audioLevels] = useState(createRealtimeVoiceAudioLevelStore);
235
287
  const stateRef = useRef(state);
236
288
  const peerRef = useRef<RTCPeerConnection | null>(null);
237
289
  const channelRef = useRef<RTCDataChannel | null>(null);
238
290
  const streamRef = useRef<MediaStream | null>(null);
239
291
  const audioRef = useRef<HTMLAudioElement | null>(null);
292
+ const audioContextRef = useRef<AudioContext | null>(null);
293
+ const inputAnalyserRef = useRef<AnalyserNode | null>(null);
294
+ const outputAnalyserRef = useRef<AnalyserNode | null>(null);
295
+ const inputMeterBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
296
+ const outputMeterBufferRef = useRef<Uint8Array<ArrayBuffer> | null>(null);
297
+ const inputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
298
+ const outputSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
299
+ const meterFrameRef = useRef<number | null>(null);
300
+ const lastMeterSampleRef = useRef(0);
240
301
  const abortRef = useRef<AbortController | null>(null);
241
302
  const handledCallsRef = useRef(new Set<string>());
242
303
  const sessionIdRef = useRef<string | undefined>(undefined);
243
304
  const startedAtRef = useRef<string | undefined>(undefined);
244
305
  const lastUserTextRef = useRef("");
245
306
  const lastAssistantTextRef = useRef("");
307
+ const transcriptThreadIdRef = useRef<string | undefined>(undefined);
308
+ const transcriptSequenceRef = useRef(0);
246
309
 
247
310
  useEffect(() => {
248
311
  stateRef.current = state;
@@ -279,6 +342,77 @@ function useRealtimeVoiceModeController(
279
342
  [syncAppState],
280
343
  );
281
344
 
345
+ const startMeterLoop = useCallback(() => {
346
+ if (meterFrameRef.current !== null) return;
347
+ const sample = (timestamp: number) => {
348
+ meterFrameRef.current = requestAnimationFrame(sample);
349
+ if (timestamp - lastMeterSampleRef.current < 50) return;
350
+ lastMeterSampleRef.current = timestamp;
351
+
352
+ const current = audioLevels.getSnapshot();
353
+ let input = current.input;
354
+ let output = current.output;
355
+ const inputAnalyser = inputAnalyserRef.current;
356
+ const inputBuffer = inputMeterBufferRef.current;
357
+ if (inputAnalyser && inputBuffer) {
358
+ inputAnalyser.getByteTimeDomainData(inputBuffer);
359
+ input = smoothRealtimeVoiceLevel(
360
+ input,
361
+ normalizeRealtimeVoiceRms(inputBuffer),
362
+ );
363
+ }
364
+ const outputAnalyser = outputAnalyserRef.current;
365
+ const outputBuffer = outputMeterBufferRef.current;
366
+ if (outputAnalyser && outputBuffer) {
367
+ outputAnalyser.getByteTimeDomainData(outputBuffer);
368
+ output = smoothRealtimeVoiceLevel(
369
+ output,
370
+ normalizeRealtimeVoiceRms(outputBuffer),
371
+ );
372
+ }
373
+ audioLevels.set({ input, output });
374
+ };
375
+ meterFrameRef.current = requestAnimationFrame(sample);
376
+ }, [audioLevels]);
377
+
378
+ const attachAudioMeter = useCallback(
379
+ (stream: MediaStream, channel: "input" | "output") => {
380
+ try {
381
+ const AudioCtor =
382
+ window.AudioContext ??
383
+ (
384
+ window as typeof window & {
385
+ webkitAudioContext?: typeof AudioContext;
386
+ }
387
+ ).webkitAudioContext;
388
+ if (!AudioCtor) return;
389
+ const context = audioContextRef.current ?? new AudioCtor();
390
+ audioContextRef.current = context;
391
+ void context.resume().catch(() => undefined);
392
+ const analyser = context.createAnalyser();
393
+ analyser.fftSize = 512;
394
+ const source = context.createMediaStreamSource(stream);
395
+ source.connect(analyser);
396
+ const buffer = new Uint8Array(analyser.frequencyBinCount);
397
+ if (channel === "input") {
398
+ inputSourceRef.current?.disconnect();
399
+ inputSourceRef.current = source;
400
+ inputAnalyserRef.current = analyser;
401
+ inputMeterBufferRef.current = buffer;
402
+ } else {
403
+ outputSourceRef.current?.disconnect();
404
+ outputSourceRef.current = source;
405
+ outputAnalyserRef.current = analyser;
406
+ outputMeterBufferRef.current = buffer;
407
+ }
408
+ startMeterLoop();
409
+ } catch {
410
+ // Audio metering is visual-only; keep the realtime call healthy.
411
+ }
412
+ },
413
+ [startMeterLoop],
414
+ );
415
+
282
416
  const cleanupTransport = useCallback(() => {
283
417
  abortRef.current?.abort();
284
418
  abortRef.current = null;
@@ -293,8 +427,25 @@ function useRealtimeVoiceModeController(
293
427
  audioRef.current.srcObject = null;
294
428
  }
295
429
  audioRef.current = null;
430
+ if (meterFrameRef.current !== null) {
431
+ cancelAnimationFrame(meterFrameRef.current);
432
+ meterFrameRef.current = null;
433
+ }
434
+ inputSourceRef.current?.disconnect();
435
+ outputSourceRef.current?.disconnect();
436
+ inputSourceRef.current = null;
437
+ outputSourceRef.current = null;
438
+ inputAnalyserRef.current = null;
439
+ outputAnalyserRef.current = null;
440
+ inputMeterBufferRef.current = null;
441
+ outputMeterBufferRef.current = null;
442
+ lastMeterSampleRef.current = 0;
443
+ const audioContext = audioContextRef.current;
444
+ audioContextRef.current = null;
445
+ if (audioContext) void audioContext.close().catch(() => undefined);
446
+ audioLevels.reset();
296
447
  handledCallsRef.current.clear();
297
- }, []);
448
+ }, [audioLevels]);
298
449
 
299
450
  const fail = useCallback(
300
451
  (message: string, options?: { openKeySettings?: boolean }) => {
@@ -342,6 +493,26 @@ function useRealtimeVoiceModeController(
342
493
  [browserTabId, transition],
343
494
  );
344
495
 
496
+ const persistCompletedTranscript = useCallback(
497
+ (event: RealtimeServerEvent) => {
498
+ const transcript = extractCompletedRealtimeVoiceTranscript(event);
499
+ const threadId = transcriptThreadIdRef.current;
500
+ if (!transcript || !threadId) return;
501
+ const sessionIdentity =
502
+ sessionIdRef.current ?? startedAtRef.current ?? "pending";
503
+ const providerIdentity =
504
+ transcript.providerId ?? `sequence-${++transcriptSequenceRef.current}`;
505
+ realtimeVoiceTranscriptRegistry.publish({
506
+ id: `realtime-voice:${sessionIdentity}:${transcript.role}:${providerIdentity}`,
507
+ threadId,
508
+ role: transcript.role,
509
+ text: transcript.text,
510
+ createdAt: new Date().toISOString(),
511
+ });
512
+ },
513
+ [],
514
+ );
515
+
345
516
  const handleServerEvent = useCallback(
346
517
  (event: RealtimeServerEvent) => {
347
518
  if (event.type === "session.created") {
@@ -367,14 +538,16 @@ function useRealtimeVoiceModeController(
367
538
  if (typeof event.transcript === "string") {
368
539
  lastAssistantTextRef.current = event.transcript;
369
540
  }
541
+ persistCompletedTranscript(event);
370
542
  syncAppState("speaking");
371
543
  } else if (
372
544
  event.type === "conversation.item.input_audio_transcription.completed"
373
545
  ) {
374
546
  if (typeof event.transcript === "string") {
375
547
  lastUserTextRef.current = event.transcript;
376
- syncAppState("working");
377
548
  }
549
+ persistCompletedTranscript(event);
550
+ syncAppState("working");
378
551
  } else if (event.type === "response.done") {
379
552
  const response = event.response;
380
553
  const status =
@@ -410,7 +583,14 @@ function useRealtimeVoiceModeController(
410
583
  transition("listening");
411
584
  }
412
585
  },
413
- [copy, fail, handleFunctionCall, syncAppState, transition],
586
+ [
587
+ copy,
588
+ fail,
589
+ handleFunctionCall,
590
+ persistCompletedTranscript,
591
+ syncAppState,
592
+ transition,
593
+ ],
414
594
  );
415
595
 
416
596
  const start = useCallback(async () => {
@@ -430,6 +610,9 @@ function useRealtimeVoiceModeController(
430
610
  lastUserTextRef.current = "";
431
611
  lastAssistantTextRef.current = "";
432
612
  sessionIdRef.current = undefined;
613
+ transcriptThreadIdRef.current =
614
+ realtimeVoiceTranscriptRegistry.activeThreadId();
615
+ transcriptSequenceRef.current = 0;
433
616
  transition("connecting");
434
617
  setChatVisible(false);
435
618
  window.dispatchEvent(new Event("agent-panel:close"));
@@ -445,6 +628,7 @@ function useRealtimeVoiceModeController(
445
628
  },
446
629
  });
447
630
  streamRef.current = stream;
631
+ attachAudioMeter(stream, "input");
448
632
 
449
633
  const peer = new RTCPeerConnection();
450
634
  peerRef.current = peer;
@@ -455,7 +639,9 @@ function useRealtimeVoiceModeController(
455
639
  audio.setAttribute("playsinline", "");
456
640
  audioRef.current = audio;
457
641
  peer.ontrack = (trackEvent) => {
458
- audio.srcObject = trackEvent.streams[0] ?? null;
642
+ const remoteStream = trackEvent.streams[0] ?? null;
643
+ audio.srcObject = remoteStream;
644
+ if (remoteStream) attachAudioMeter(remoteStream, "output");
459
645
  void audio.play().catch(() => undefined);
460
646
  };
461
647
 
@@ -501,15 +687,42 @@ function useRealtimeVoiceModeController(
501
687
  const status = (startError as { status?: unknown })?.status;
502
688
  fail(errorMessage(startError), { openKeySettings: status === 400 });
503
689
  }
504
- }, [browserTabId, copy, fail, handleServerEvent, transition]);
690
+ }, [
691
+ attachAudioMeter,
692
+ browserTabId,
693
+ copy,
694
+ fail,
695
+ handleServerEvent,
696
+ transition,
697
+ ]);
505
698
 
506
699
  const end = useCallback(() => {
507
700
  if (stateRef.current === "idle" || stateRef.current === "ending") return;
701
+ const transcriptThreadId = transcriptThreadIdRef.current;
702
+ const activeThreadId = realtimeVoiceTranscriptRegistry.activeThreadId();
508
703
  transition("ending");
509
704
  cleanupTransport();
510
705
  setError(null);
511
706
  sessionIdRef.current = undefined;
512
707
  startedAtRef.current = undefined;
708
+ transcriptThreadIdRef.current = undefined;
709
+ setChatVisible(true);
710
+ if (
711
+ shouldRestoreRealtimeVoiceTranscriptThread(
712
+ transcriptThreadId,
713
+ activeThreadId,
714
+ )
715
+ ) {
716
+ requestAgentChatThreadOpen({
717
+ threadId: transcriptThreadId,
718
+ // The request is delivered asynchronously. Re-checking this at the
719
+ // receiver prevents a navigation that happened during that gap from
720
+ // being overwritten.
721
+ onlyIfActiveThreadId: transcriptThreadId,
722
+ });
723
+ } else {
724
+ window.dispatchEvent(new Event("agent-panel:open"));
725
+ }
513
726
  transition("idle");
514
727
  }, [cleanupTransport, transition]);
515
728
 
@@ -538,6 +751,7 @@ function useRealtimeVoiceModeController(
538
751
  active: state !== "idle",
539
752
  errorMessage: error,
540
753
  chatVisible,
754
+ audioLevels,
541
755
  start,
542
756
  end,
543
757
  toggleChat,
@@ -568,10 +782,10 @@ export function RealtimeVoiceModeProvider({
568
782
  state={voice.state === "idle" ? "ending" : voice.state}
569
783
  copy={copy}
570
784
  chatVisible={voice.chatVisible}
785
+ audioLevels={voice.audioLevels}
571
786
  onToggleChat={voice.toggleChat}
572
787
  onEndVoiceMode={voice.end}
573
788
  errorMessage={voice.errorMessage}
574
- className="z-[270]"
575
789
  />,
576
790
  document.body,
577
791
  )