@runtypelabs/persona 4.25.0 → 4.26.1

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 (236) hide show
  1. package/dist/animations/glyph-cycle.d.cts +1 -1
  2. package/dist/animations/glyph-cycle.d.ts +1 -1
  3. package/dist/animations/{types-Db61aI9J.d.cts → types-BwKTZhg9.d.cts} +14 -0
  4. package/dist/animations/{types-Db61aI9J.d.ts → types-BwKTZhg9.d.ts} +14 -0
  5. package/dist/animations/wipe.d.cts +1 -1
  6. package/dist/animations/wipe.d.ts +1 -1
  7. package/dist/animations-extra.d.cts +14 -0
  8. package/dist/animations-extra.d.ts +14 -0
  9. package/dist/approval-ui-QAKWNFNM.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +186 -8
  12. package/dist/approval-ui.d.ts +186 -8
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-IGB5YKLO.js → artifacts-ui-NJGMM27I.js} +1 -1
  15. package/dist/artifacts-ui.cjs +4 -4
  16. package/dist/artifacts-ui.d.cts +186 -8
  17. package/dist/artifacts-ui.d.ts +186 -8
  18. package/dist/artifacts-ui.js +5 -5
  19. package/dist/chunk-2ANTRKTV.js +1 -0
  20. package/dist/chunk-4HPTPUC6.js +1 -0
  21. package/dist/chunk-6V3MNEKB.js +1 -0
  22. package/dist/chunk-GKPCIQPB.js +1 -0
  23. package/dist/chunk-HGUCGM3K.js +9 -0
  24. package/dist/chunk-IO5VVUKP.js +3 -0
  25. package/dist/chunk-J4J3Y2P2.js +1 -0
  26. package/dist/chunk-K6HKIP5K.js +1 -0
  27. package/dist/chunk-K7NA7G7J.js +1 -0
  28. package/dist/chunk-MXUTS3LB.js +1 -0
  29. package/dist/chunk-PDPVORUF.js +1 -0
  30. package/dist/chunk-QRKGQPU7.js +1 -0
  31. package/dist/chunk-S6PBWE75.js +1 -0
  32. package/dist/chunk-T7QM7YMF.js +1 -0
  33. package/dist/chunk-TR2H475E.js +1 -0
  34. package/dist/chunk-UPO4GUFC.js +1 -0
  35. package/dist/chunk-ZC66NAIR.js +1 -0
  36. package/dist/client-history-GOZWBDA5.js +1 -0
  37. package/dist/client-history.js +1 -0
  38. package/dist/client-stream-42FOI3EC.js +4 -0
  39. package/dist/client-stream.js +5 -0
  40. package/dist/codegen.cjs +1 -1
  41. package/dist/codegen.js +1 -1
  42. package/dist/context-mentions-E7HJYWO6.js +161 -0
  43. package/dist/{context-mentions-inline-I2RDEBAK.js → context-mentions-inline-QVESLW55.js} +1 -1
  44. package/dist/context-mentions.cjs +7 -7
  45. package/dist/context-mentions.d.cts +186 -8
  46. package/dist/context-mentions.d.ts +186 -8
  47. package/dist/context-mentions.js +8 -8
  48. package/dist/event-stream-view-FOIJHFQO.js +1 -0
  49. package/dist/event-stream-view.cjs +1 -1
  50. package/dist/event-stream-view.d.cts +189 -31
  51. package/dist/event-stream-view.d.ts +189 -31
  52. package/dist/event-stream-view.js +1 -1
  53. package/dist/forms-ui.d.cts +227 -667
  54. package/dist/forms-ui.d.ts +227 -667
  55. package/dist/history-shell-QDQ44PSE.js +3 -0
  56. package/dist/history-shell.js +3 -0
  57. package/dist/{history-view-BD6OXLNA.js → history-view-NCFBI5GR.js} +2 -2
  58. package/dist/history-view.cjs +2 -2
  59. package/dist/history-view.d.cts +35 -1
  60. package/dist/history-view.d.ts +35 -1
  61. package/dist/history-view.js +2 -2
  62. package/dist/index.cjs +80 -74
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +233 -738
  65. package/dist/index.d.ts +233 -738
  66. package/dist/index.global.js +3 -215
  67. package/dist/index.global.js.map +1 -1
  68. package/dist/index.js +81 -75
  69. package/dist/index.js.map +1 -1
  70. package/dist/install.global.js +1 -1
  71. package/dist/install.global.js.map +1 -1
  72. package/dist/launcher.global.js +3 -67
  73. package/dist/launcher.global.js.map +1 -1
  74. package/dist/markdown-parsers.js +22 -22
  75. package/dist/runtype-tts-entry-NFBVWR7N.js +1 -0
  76. package/dist/runtype-tts.js +1 -1
  77. package/dist/session-actions-ISSKSI66.js +1 -0
  78. package/dist/session-actions.js +1 -0
  79. package/dist/session-reconnect.d.cts +186 -8
  80. package/dist/session-reconnect.d.ts +186 -8
  81. package/dist/smart-dom-reader.cjs +13 -13
  82. package/dist/smart-dom-reader.d.cts +186 -8
  83. package/dist/smart-dom-reader.d.ts +186 -8
  84. package/dist/smart-dom-reader.js +11 -11
  85. package/dist/theme-editor-preview.cjs +116 -98
  86. package/dist/theme-editor-preview.d.cts +188 -8
  87. package/dist/theme-editor-preview.d.ts +188 -8
  88. package/dist/theme-editor-preview.js +49 -54
  89. package/dist/theme-editor.cjs +2 -2
  90. package/dist/theme-editor.d.cts +187 -23
  91. package/dist/theme-editor.d.ts +187 -23
  92. package/dist/theme-editor.js +2 -2
  93. package/dist/theme-reference.d.cts +14 -0
  94. package/dist/theme-reference.d.ts +14 -0
  95. package/dist/ui-extras-entry-WAYFKVNW.js +3 -0
  96. package/dist/ui-extras.js +72 -0
  97. package/dist/voice-runtime-XLU6YK2N.js +17 -0
  98. package/dist/voice-runtime.cjs +17 -1
  99. package/dist/voice-runtime.d.cts +11656 -232
  100. package/dist/voice-runtime.d.ts +11656 -232
  101. package/dist/voice-runtime.js +17 -1
  102. package/dist/voice-worklet-player.cjs +19 -4
  103. package/dist/voice-worklet-player.d.cts +9 -10
  104. package/dist/voice-worklet-player.d.ts +9 -10
  105. package/dist/voice-worklet-player.js +19 -4
  106. package/dist/webmcp-polyfill.js +1 -1
  107. package/dist/webmcp-runtime-V6ZWW3PI.js +6 -0
  108. package/dist/webmcp-runtime.cjs +2 -2
  109. package/dist/webmcp-runtime.d.cts +1 -33
  110. package/dist/webmcp-runtime.d.ts +1 -33
  111. package/dist/webmcp-runtime.js +2 -2
  112. package/dist/widget.css +1 -1
  113. package/package.json +11 -5
  114. package/src/approval-paused-tool-settle.test.ts +325 -0
  115. package/src/client-approve.test.ts +311 -0
  116. package/src/client-history-eager.ts +9 -0
  117. package/src/client-history-loader.ts +23 -0
  118. package/src/client-history.ts +579 -0
  119. package/src/client-resume.ts +177 -0
  120. package/src/client-stream-eager.ts +12 -0
  121. package/src/client-stream-loader.ts +25 -0
  122. package/src/client-stream.ts +1880 -0
  123. package/src/client.test.ts +31 -1
  124. package/src/client.ts +293 -2607
  125. package/src/components/approval-actions.test.ts +27 -0
  126. package/src/components/approval-actions.ts +27 -3
  127. package/src/components/message-bubble.test.ts +19 -0
  128. package/src/components/message-bubble.ts +2 -0
  129. package/src/components/registry.ts +6 -6
  130. package/src/components/tool-bubble.test.ts +9 -0
  131. package/src/components/tool-bubble.ts +1 -0
  132. package/src/history-shell-bundle.test.ts +54 -0
  133. package/src/history-shell-eager.ts +12 -0
  134. package/src/history-shell-loader.ts +27 -0
  135. package/src/history-shell.ts +2362 -0
  136. package/src/history-view-loader.ts +2 -0
  137. package/src/history-view.ts +3 -0
  138. package/src/index-core.ts +6 -0
  139. package/src/index-global.ts +90 -248
  140. package/src/index.ts +13 -0
  141. package/src/install.ts +6 -4
  142. package/src/internal/runtype-history-provider.test.ts +1 -0
  143. package/src/internal/runtype-history-provider.ts +28 -13
  144. package/src/plugins/registry.ts +8 -8
  145. package/src/session-actions-chunk.test.ts +55 -0
  146. package/src/session-actions-eager.ts +11 -0
  147. package/src/session-actions-loader.ts +37 -0
  148. package/src/session-actions.ts +909 -0
  149. package/src/session-history.test.ts +3 -1
  150. package/src/session.ts +895 -1538
  151. package/src/session.voice.test.ts +1171 -5
  152. package/src/styles/widget.css +58 -0
  153. package/src/theme-editor/state.ts +91 -91
  154. package/src/types.ts +204 -5
  155. package/src/ui-ask-user-sheet.ts +382 -0
  156. package/src/ui-extras-eager.ts +10 -0
  157. package/src/ui-extras-entry.ts +9 -0
  158. package/src/ui-extras-loader.ts +26 -0
  159. package/src/ui.artifact-rail-resize.test.ts +2 -1
  160. package/src/ui.ask-user-chunk.test.ts +93 -0
  161. package/src/ui.history-chunk.test.ts +2 -1
  162. package/src/ui.history-config-provider.test.ts +2 -1
  163. package/src/ui.history-confirm-fallback.test.ts +142 -0
  164. package/src/ui.history-controller.test.ts +2 -1
  165. package/src/ui.history-lifecycle.test.ts +2 -1
  166. package/src/ui.history-render-hooks.test.ts +2 -1
  167. package/src/ui.history-shell.test.ts +2 -1
  168. package/src/ui.rail-overlay.test.ts +2 -1
  169. package/src/ui.rail-resize.test.ts +2 -1
  170. package/src/ui.rail-trigger-pending.test.ts +2 -1
  171. package/src/ui.ts +340 -2770
  172. package/src/utils/attachment-manager.ts +67 -67
  173. package/src/utils/constants.ts +3 -0
  174. package/src/utils/context-mention-controller.ts +323 -323
  175. package/src/utils/context-mention-manager.ts +40 -40
  176. package/src/utils/context-mention-orchestrator.ts +28 -0
  177. package/src/utils/event-stream-buffer.ts +50 -50
  178. package/src/utils/event-stream-store.ts +43 -43
  179. package/src/utils/throughput-tracker.ts +34 -34
  180. package/src/utils/virtual-scroller.ts +67 -67
  181. package/src/utils/visitor-store.test.ts +50 -0
  182. package/src/utils/visitor-store.ts +23 -5
  183. package/src/voice/__fixtures__/voice-wire/README.md +1 -0
  184. package/src/voice/__fixtures__/voice-wire/client/cancel.json +3 -0
  185. package/src/voice/__fixtures__/voice-wire/client/context.json +4 -0
  186. package/src/voice/__fixtures__/voice-wire/client/delegation_result.json +6 -0
  187. package/src/voice/__fixtures__/voice-wire/client/delegation_update.json +6 -0
  188. package/src/voice/__fixtures__/voice-wire/client/ping.json +3 -0
  189. package/src/voice/__fixtures__/voice-wire/client/playback_progress.json +5 -0
  190. package/src/voice/__fixtures__/voice-wire/client/start.json +3 -0
  191. package/src/voice/__fixtures__/voice-wire/server/artifact.json +11 -0
  192. package/src/voice/__fixtures__/voice-wire/server/attached.json +4 -0
  193. package/src/voice/__fixtures__/voice-wire/server/audio_clear.json +4 -0
  194. package/src/voice/__fixtures__/voice-wire/server/audio_end.json +3 -0
  195. package/src/voice/__fixtures__/voice-wire/server/delegation_cancelled.json +5 -0
  196. package/src/voice/__fixtures__/voice-wire/server/delegation_completed.json +8 -0
  197. package/src/voice/__fixtures__/voice-wire/server/delegation_started.json +15 -0
  198. package/src/voice/__fixtures__/voice-wire/server/error.json +6 -0
  199. package/src/voice/__fixtures__/voice-wire/server/metrics.json +10 -0
  200. package/src/voice/__fixtures__/voice-wire/server/pong.json +3 -0
  201. package/src/voice/__fixtures__/voice-wire/server/session_config.json +16 -0
  202. package/src/voice/__fixtures__/voice-wire/server/transcript_final.json +7 -0
  203. package/src/voice/__fixtures__/voice-wire/server/transcript_interim.json +4 -0
  204. package/src/voice/__fixtures__/voice-wire/server/transcript_partial.json +6 -0
  205. package/src/voice/__fixtures__/voice-wire/server/transcript_update.json +10 -0
  206. package/src/voice/__fixtures__/voice-wire/server/warning.json +5 -0
  207. package/src/voice/audio-playback-manager.test.ts +57 -0
  208. package/src/voice/audio-playback-manager.ts +120 -98
  209. package/src/voice/browser-voice-provider.ts +37 -37
  210. package/src/voice/fallback-speech-engine.ts +22 -16
  211. package/src/voice/keyed-voice-transcript.ts +314 -0
  212. package/src/voice/mic-state-styles.ts +182 -0
  213. package/src/voice/read-aloud-controller.ts +46 -44
  214. package/src/voice/runtype-speech-engine.ts +35 -33
  215. package/src/voice/runtype-voice-provider.ts +680 -189
  216. package/src/voice/session-voice-wiring.ts +266 -0
  217. package/src/voice/voice-delegation.test.ts +71 -0
  218. package/src/voice/voice-delegation.ts +364 -0
  219. package/src/voice/voice-turn-tracker.ts +6 -6
  220. package/src/voice/voice.test.ts +1060 -8
  221. package/src/voice/worklet-playback-engine.test.ts +81 -0
  222. package/src/voice/worklet-playback-engine.ts +21 -3
  223. package/src/voice-runtime-loader.ts +4 -1
  224. package/src/voice-runtime.ts +7 -0
  225. package/src/webmcp-runtime-entry.ts +41 -39
  226. package/dist/approval-ui-3NMYNXBD.js +0 -1
  227. package/dist/chunk-5EIIHQLQ.js +0 -1
  228. package/dist/chunk-7CO4T5KB.js +0 -1
  229. package/dist/chunk-EFJBUH5F.js +0 -1
  230. package/dist/chunk-R2TMIMDI.js +0 -3
  231. package/dist/chunk-UTVLOTWB.js +0 -9
  232. package/dist/context-mentions-45MODWOY.js +0 -161
  233. package/dist/event-stream-view-GL3ADRY2.js +0 -1
  234. package/dist/runtype-tts-entry-UJAEF7NZ.js +0 -1
  235. package/dist/voice-runtime-7DA7MJH4.js +0 -1
  236. package/dist/webmcp-runtime-JI7T4QYF.js +0 -6
@@ -30,27 +30,33 @@ export class FallbackSpeechEngine implements SpeechEngine {
30
30
 
31
31
  // Whichever engine is currently driving playback, so pause/resume/stop route
32
32
  // to the right one after a fallback has (or hasn't) kicked in.
33
- private active: SpeechEngine;
33
+ #active: SpeechEngine;
34
34
 
35
+ readonly #primary: SpeechEngine;
36
+ readonly #fallback: SpeechEngine;
37
+ readonly #options: FallbackSpeechEngineOptions;
35
38
  constructor(
36
- private readonly primary: SpeechEngine,
37
- private readonly fallback: SpeechEngine,
38
- private readonly options: FallbackSpeechEngineOptions = {},
39
+ primary: SpeechEngine,
40
+ fallback: SpeechEngine,
41
+ options: FallbackSpeechEngineOptions = {},
39
42
  ) {
40
- this.active = primary;
43
+ this.#primary = primary;
44
+ this.#fallback = fallback;
45
+ this.#options = options;
46
+ this.#active = primary;
41
47
  }
42
48
 
43
49
  // Pause/resume only matters once something is playing, and both built-in
44
50
  // engines support it; report the active engine's capability.
45
51
  get supportsPause(): boolean {
46
- return this.active.supportsPause;
52
+ return this.#active.supportsPause;
47
53
  }
48
54
 
49
55
  speak(request: SpeechRequest, callbacks: SpeechCallbacks): void {
50
- this.active = this.primary;
56
+ this.#active = this.#primary;
51
57
  let started = false;
52
58
 
53
- this.primary.speak(request, {
59
+ this.#primary.speak(request, {
54
60
  onStart: () => {
55
61
  started = true;
56
62
  callbacks.onStart?.();
@@ -63,27 +69,27 @@ export class FallbackSpeechEngine implements SpeechEngine {
63
69
  return;
64
70
  }
65
71
  // Pre-start failure: silently hand the utterance to the fallback.
66
- this.options.onFallback?.(error);
67
- this.active = this.fallback;
68
- this.fallback.speak(request, callbacks);
72
+ this.#options.onFallback?.(error);
73
+ this.#active = this.#fallback;
74
+ this.#fallback.speak(request, callbacks);
69
75
  },
70
76
  });
71
77
  }
72
78
 
73
79
  pause(): void {
74
- this.active.pause();
80
+ this.#active.pause();
75
81
  }
76
82
 
77
83
  resume(): void {
78
- this.active.resume();
84
+ this.#active.resume();
79
85
  }
80
86
 
81
87
  stop(): void {
82
- this.active.stop();
88
+ this.#active.stop();
83
89
  }
84
90
 
85
91
  destroy(): void {
86
- this.primary.destroy?.();
87
- this.fallback.destroy?.();
92
+ this.#primary.destroy?.();
93
+ this.#fallback.destroy?.();
88
94
  }
89
95
  }
@@ -0,0 +1,314 @@
1
+ import type { AgentWidgetMessage, InjectMessageOptions } from "../types";
2
+
3
+ /** The session surface the keyed reconciler drives. */
4
+ export interface KeyedVoiceTranscriptHost {
5
+ find(id: string): AgentWidgetMessage | undefined;
6
+ inject(options: InjectMessageOptions): AgentWidgetMessage;
7
+ upsert(message: AgentWidgetMessage): void;
8
+ /** Clear `streaming` / `voiceProcessing` on these messages in one update. */
9
+ settle(ids: Set<string>): void;
10
+ setStreaming(streaming: boolean): void;
11
+ /** The provider already played this reply's audio: browser TTS must skip it. */
12
+ markSpoken(id: string): void;
13
+ }
14
+
15
+ /** Bubbles owned by one provider turnId. */
16
+ type KeyedTurn = {
17
+ userId?: string;
18
+ assistantId?: string;
19
+ userFinal?: boolean;
20
+ assistantFinal?: boolean;
21
+ userStartMs?: number;
22
+ assistantStartMs?: number;
23
+ /** Submitted as a chat turn: the chat pipeline renders its reply. */
24
+ claimed?: boolean;
25
+ };
26
+
27
+ /** Loose text match between a transcript and the delegated request text. */
28
+ const normalize = (text: string) => text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
29
+
30
+ /**
31
+ * Reconciles turn-keyed voice transcripts (full-duplex providers such as
32
+ * GPT-Live) into chat bubbles: each `(turnId, role)` owns one bubble that later
33
+ * frames replace in place, so overlapping turns stay separate and may arrive in
34
+ * any order. No empty assistant placeholder is injected; the session's
35
+ * `streaming` flag drives the standalone typing indicator instead. A new bubble
36
+ * with a `startMs` sorts above any keyed bubble of the call that started later,
37
+ * so a late user transcript still renders above the reply it prompted.
38
+ *
39
+ * Cancelled output is the provider's to drop (until the server acknowledges
40
+ * the cancel): every reply that reaches `apply` after a stop renders, which
41
+ * keeps a later answer (e.g. a delegation result) visible while its audio plays.
42
+ *
43
+ * Ships in the lazy voice-runtime chunk: only sessions with a voice provider
44
+ * ever construct one.
45
+ */
46
+ export class KeyedVoiceTranscript {
47
+ private turns = new Map<string, KeyedTurn>();
48
+ private latestId: string | null = null;
49
+ private cancelled = new Set<string>();
50
+
51
+ constructor(private host: KeyedVoiceTranscriptHost) {}
52
+
53
+ apply(
54
+ role: "user" | "assistant",
55
+ text: string,
56
+ isFinal: boolean,
57
+ turnId: string,
58
+ startMs?: number,
59
+ caption?: boolean,
60
+ ): void {
61
+ const host = this.host;
62
+ // Speech-to-speech transcripts can carry a leading space (" What are…").
63
+ text = text.trim();
64
+ let turn = this.turns.get(turnId);
65
+ if (!turn) {
66
+ turn = {};
67
+ this.turns.set(turnId, turn);
68
+ this.latestId = turnId;
69
+ }
70
+
71
+ if (role === "user") {
72
+ turn.userFinal = isFinal;
73
+ const existing = turn.userId ? host.find(turn.userId) : undefined;
74
+ if (existing) {
75
+ const moved = turn.userStartMs === undefined && startMs !== undefined;
76
+ if (moved) turn.userStartMs = startMs;
77
+ host.upsert({
78
+ ...existing,
79
+ content: text,
80
+ voiceProcessing: !isFinal,
81
+ ...(moved && this.placeBefore(this.startedAfter(startMs))),
82
+ });
83
+ } else {
84
+ // A user transcript that lands after its own turn's reply started still
85
+ // renders above that reply: borrow the reply's timestamp, sort just ahead.
86
+ turn.userStartMs = startMs;
87
+ const reply = turn.assistantId ? host.find(turn.assistantId) : undefined;
88
+ turn.userId = host.inject({
89
+ role: "user",
90
+ content: text,
91
+ streaming: false,
92
+ voiceProcessing: !isFinal,
93
+ voiceCaption: caption,
94
+ ...this.placeBefore(this.startedAfter(startMs) ?? reply),
95
+ }).id;
96
+ }
97
+ } else if (!this.cancelled.has(turnId)) {
98
+ turn.assistantFinal = isFinal;
99
+ const existing = turn.assistantId ? host.find(turn.assistantId) : undefined;
100
+ if (existing) {
101
+ // A timestamp that arrives after the bubble did re-positions it.
102
+ const moved = turn.assistantStartMs === undefined && startMs !== undefined;
103
+ if (moved) turn.assistantStartMs = startMs;
104
+ host.upsert({
105
+ ...existing,
106
+ content: text,
107
+ streaming: !isFinal,
108
+ voiceProcessing: !isFinal,
109
+ ...(moved && this.placeBefore(this.startedAfter(startMs))),
110
+ });
111
+ } else if (text) {
112
+ turn.assistantStartMs = startMs;
113
+ turn.assistantId = host.inject({
114
+ role: "assistant",
115
+ content: text,
116
+ streaming: !isFinal,
117
+ voiceProcessing: !isFinal,
118
+ voiceCaption: caption,
119
+ ...this.placeBefore(this.startedAfter(startMs)),
120
+ }).id;
121
+ }
122
+ if (isFinal && turn.assistantId) host.markSpoken(turn.assistantId);
123
+ }
124
+ this.sync();
125
+ }
126
+
127
+ /**
128
+ * The user bubble a delegated request came from, claimed so it is submitted
129
+ * once. With utterance ids it is the last one's bubble; one that hasn't been
130
+ * transcribed yet is created now (from `userText`), so its transcript later
131
+ * fills it instead of adding a second bubble. Without an id it is the newest
132
+ * unclaimed bubble whose text matches exactly, else one where either text
133
+ * is a prefix of the other (the request can beat the final transcript).
134
+ * `null` when nothing matches: never someone else's bubble.
135
+ *
136
+ * `userUtteranceIds` lists every utterance a split request joins (the last
137
+ * is the one submitted). The others are claimed too: they stay displayed as
138
+ * captions, never sent and never claimed again. One not transcribed yet
139
+ * is reserved, so its transcript later renders as a caption.
140
+ */
141
+ claimUserTurn(userText: string, userUtteranceIds: string[] = []): string | null {
142
+ const requestId = userUtteranceIds[userUtteranceIds.length - 1];
143
+ for (const id of userUtteranceIds) {
144
+ if (id === requestId) continue;
145
+ if (!this.turns.has(id)) this.turns.set(id, {});
146
+ this.turns.get(id)!.claimed = true;
147
+ }
148
+ let pick: KeyedTurn | undefined;
149
+ if (requestId) {
150
+ pick = this.turns.get(requestId);
151
+ if (!pick) {
152
+ pick = {};
153
+ this.turns.set(requestId, pick);
154
+ }
155
+ if (pick.claimed) return null;
156
+ if (!pick.userId || !this.host.find(pick.userId)) {
157
+ pick.userFinal = true;
158
+ pick.userId = this.host.inject({ role: "user", content: userText.trim() }).id;
159
+ }
160
+ } else {
161
+ const want = normalize(userText);
162
+ const open = [...this.turns.values()]
163
+ .reverse()
164
+ .flatMap((turn) => {
165
+ const bubble = turn.userId && !turn.claimed ? this.host.find(turn.userId) : undefined;
166
+ return bubble ? [{ turn, text: normalize(bubble.content) }] : [];
167
+ })
168
+ .filter(({ text }) => text && want);
169
+ pick = (
170
+ open.find(({ text }) => text === want) ??
171
+ open.find(({ text }) => text.startsWith(want) || want.startsWith(text))
172
+ )?.turn;
173
+ }
174
+ if (!pick) return null;
175
+ // No sync: the chat turn about to start owns the streaming flag.
176
+ pick.claimed = true;
177
+ return pick.userId!;
178
+ }
179
+
180
+ /** Explicit stop: drop the rest of every in-flight reply, else the awaited one. */
181
+ cancel(): void {
182
+ let targets = this.inFlight();
183
+ if (targets.length === 0) {
184
+ const awaiting = this.awaiting();
185
+ if (!awaiting) return;
186
+ targets = [awaiting];
187
+ }
188
+ for (const id of targets) this.cancelled.add(id);
189
+ this.closeReplies(targets);
190
+ this.sync();
191
+ }
192
+
193
+ /**
194
+ * Voice error: close every in-flight reply as-is and show the error text (as
195
+ * the awaited turn's reply, when one is waiting), so `streaming` clears.
196
+ */
197
+ fail(errorText: string): void {
198
+ const awaitingId = this.awaiting();
199
+ const inFlight = this.inFlight();
200
+ if (!awaitingId && inFlight.length === 0) return;
201
+ for (const id of inFlight) this.turns.get(id)!.assistantFinal = true;
202
+ this.closeReplies(inFlight);
203
+ const msg = this.host.inject({
204
+ role: "assistant",
205
+ content: errorText,
206
+ streaming: false,
207
+ voiceProcessing: false,
208
+ });
209
+ const awaiting = awaitingId ? this.turns.get(awaitingId) : undefined;
210
+ if (awaiting) {
211
+ awaiting.assistantId = msg.id;
212
+ awaiting.assistantFinal = true;
213
+ }
214
+ this.sync();
215
+ }
216
+
217
+ /** Call ended: freeze every keyed bubble as-is and forget the turns. */
218
+ settle(): void {
219
+ if (this.turns.size === 0) return;
220
+ const wasPending = this.pending();
221
+ const ids = new Set<string>();
222
+ for (const turn of this.turns.values()) {
223
+ if (turn.userId) ids.add(turn.userId);
224
+ if (turn.assistantId) {
225
+ ids.add(turn.assistantId);
226
+ this.host.markSpoken(turn.assistantId);
227
+ }
228
+ }
229
+ this.turns.clear();
230
+ this.latestId = null;
231
+ this.host.settle(ids);
232
+ if (wasPending) this.host.setStreaming(false);
233
+ }
234
+
235
+ /** The call's earliest-starting keyed bubble that began after `startMs`. */
236
+ private startedAfter(startMs: number | undefined): AgentWidgetMessage | undefined {
237
+ if (startMs === undefined) return undefined;
238
+ let best: { start: number; id: string } | undefined;
239
+ for (const turn of this.turns.values()) {
240
+ for (const [id, start] of [
241
+ [turn.userId, turn.userStartMs],
242
+ [turn.assistantId, turn.assistantStartMs],
243
+ ] as const) {
244
+ if (id && start !== undefined && start > startMs && (!best || start < best.start)) {
245
+ best = { start, id };
246
+ }
247
+ }
248
+ }
249
+ return best && this.host.find(best.id);
250
+ }
251
+
252
+ /**
253
+ * Sort a bubble just ahead of `next` (messages order by time, then
254
+ * sequence): halfway between `next` and the keyed bubbles already placed
255
+ * ahead of it. Those all started earlier (else one of them would be
256
+ * `next`), so bubbles placed ahead of the same reply stay in `startMs`
257
+ * order. Sequences are timestamp-sized, so halving, not a tiny fixed
258
+ * offset, keeps them distinct.
259
+ */
260
+ private placeBefore(next: AgentWidgetMessage | undefined) {
261
+ if (!next) return undefined;
262
+ const top = next.sequence ?? 0;
263
+ let low = top - 1;
264
+ for (const turn of this.turns.values()) {
265
+ for (const id of [turn.userId, turn.assistantId]) {
266
+ const placed = id ? this.host.find(id) : undefined;
267
+ const seq = placed?.sequence;
268
+ if (placed?.createdAt === next.createdAt && seq !== undefined && seq > low && seq < top) {
269
+ low = seq;
270
+ }
271
+ }
272
+ }
273
+ return { createdAt: next.createdAt, sequence: (low + top) / 2 };
274
+ }
275
+
276
+ private closeReplies(turnIds: string[]): void {
277
+ const ids = new Set<string>();
278
+ for (const id of turnIds) {
279
+ const assistantId = this.turns.get(id)?.assistantId;
280
+ if (assistantId) {
281
+ ids.add(assistantId);
282
+ this.host.markSpoken(assistantId);
283
+ }
284
+ }
285
+ if (ids.size > 0) this.host.settle(ids);
286
+ }
287
+
288
+ /** Turns whose assistant bubble is visible but not yet final. */
289
+ private inFlight(): string[] {
290
+ const ids: string[] = [];
291
+ for (const [id, turn] of this.turns) {
292
+ if (turn.assistantId && !turn.assistantFinal && !this.cancelled.has(id)) ids.push(id);
293
+ }
294
+ return ids;
295
+ }
296
+
297
+ /** The newest turn has a final user utterance and no reply text yet. */
298
+ private awaiting(): string | null {
299
+ const id = this.latestId;
300
+ if (!id) return null;
301
+ const turn = this.turns.get(id);
302
+ return turn?.userFinal && !turn.assistantId && !turn.claimed && !this.cancelled.has(id)
303
+ ? id
304
+ : null;
305
+ }
306
+
307
+ private pending(): boolean {
308
+ return this.awaiting() !== null || this.inFlight().length > 0;
309
+ }
310
+
311
+ private sync(): void {
312
+ this.host.setStreaming(this.pending());
313
+ }
314
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Mic-button state styling for session-owned voice providers (Runtype /
3
+ * custom): recording, processing, speaking, and the reset back to idle.
4
+ *
5
+ * Ships in the lazy voice-runtime chunk (exported from `voice-runtime.ts`):
6
+ * these states only arise from a provider, which `setupVoice` constructs from
7
+ * that same chunk, so pages without session voice never download it. The UI
8
+ * passes its live closure state through `MicStateContext`; the icon renderer
9
+ * comes from core so this chunk never carries its own icon registry.
10
+ */
11
+ import type { AgentWidgetConfig } from "../types";
12
+ import type { AgentWidgetSession } from "../session";
13
+
14
+ /** Snapshot of the idle mic, shared with the UI's browser-dictation path. */
15
+ export type OriginalMicStyles = {
16
+ backgroundColor: string;
17
+ color: string;
18
+ borderColor: string;
19
+ iconName: string;
20
+ iconSize: number;
21
+ };
22
+
23
+ export type MicStateContext = {
24
+ mic: () => HTMLButtonElement | null;
25
+ config: () => AgentWidgetConfig;
26
+ session: () => AgentWidgetSession;
27
+ original: OriginalMicStyles | null;
28
+ setMicState: (state: "idle" | "recording" | "processing" | "speaking") => void;
29
+ renderIcon: (name: string, size: number, color: string, strokeWidth: number) => SVGElement | null;
30
+ iconFallbackPx: number;
31
+ };
32
+
33
+ export type MicStateStyles = {
34
+ recording: () => void;
35
+ processing: () => void;
36
+ speaking: () => void;
37
+ reset: () => void;
38
+ };
39
+
40
+ export const createMicStateStyles = (ctx: MicStateContext): MicStateStyles => {
41
+ const storeOriginalMicStyles = (micButton: HTMLButtonElement) => {
42
+ if (ctx.original) return; // Already stored
43
+ const voiceConfig = ctx.config().voiceRecognition ?? {};
44
+ ctx.original = {
45
+ backgroundColor: micButton.style.backgroundColor,
46
+ color: micButton.style.color,
47
+ borderColor: micButton.style.borderColor,
48
+ iconName: voiceConfig.iconName ?? "mic",
49
+ iconSize: parseFloat(voiceConfig.iconSize ?? "") || ctx.iconFallbackPx,
50
+ };
51
+ };
52
+
53
+ /** Swap the mic button's SVG icon */
54
+ const swapMicIcon = (micButton: HTMLButtonElement, iconName: string, color: string) => {
55
+ const existingSvg = micButton.querySelector("svg");
56
+ if (existingSvg) existingSvg.remove();
57
+ const size =
58
+ ctx.original?.iconSize ??
59
+ (parseFloat(ctx.config().voiceRecognition?.iconSize ?? "") || ctx.iconFallbackPx);
60
+ const newSvg = ctx.renderIcon(iconName, size, color, 1.5);
61
+ if (newSvg) micButton.appendChild(newSvg);
62
+ };
63
+
64
+ /** Remove all voice state CSS classes */
65
+ const removeAllVoiceStateClasses = (micButton: HTMLButtonElement) => {
66
+ micButton.classList.remove("persona-voice-recording", "persona-voice-processing", "persona-voice-speaking");
67
+ };
68
+
69
+ const recording = () => {
70
+ const micButton = ctx.mic();
71
+ if (!micButton) return;
72
+ storeOriginalMicStyles(micButton);
73
+ const voiceConfig = ctx.config().voiceRecognition ?? {};
74
+ const recordingBackgroundColor = voiceConfig.recordingBackgroundColor;
75
+ const recordingIconColor = voiceConfig.recordingIconColor;
76
+ const recordingBorderColor = voiceConfig.recordingBorderColor;
77
+ removeAllVoiceStateClasses(micButton);
78
+ micButton.classList.add("persona-voice-recording");
79
+ ctx.setMicState("recording");
80
+ micButton.style.backgroundColor = recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)";
81
+ micButton.style.color = recordingIconColor ?? "var(--persona-voice-recording-indicator, #ffffff)";
82
+ if (recordingIconColor) {
83
+ const svg = micButton.querySelector("svg");
84
+ if (svg) svg.setAttribute("stroke", recordingIconColor);
85
+ }
86
+ if (recordingBorderColor) micButton.style.borderColor = recordingBorderColor;
87
+ micButton.setAttribute("aria-label", "Stop voice recognition");
88
+ };
89
+
90
+ const processing = () => {
91
+ const micButton = ctx.mic();
92
+ if (!micButton) return;
93
+ storeOriginalMicStyles(micButton);
94
+ const voiceConfig = ctx.config().voiceRecognition ?? {};
95
+ const interruptionMode = ctx.session().getVoiceInterruptionMode();
96
+ const originalMicStyles = ctx.original;
97
+ const iconName = voiceConfig.processingIconName ?? "loader";
98
+ const iconColor = voiceConfig.processingIconColor ?? originalMicStyles?.color ?? "";
99
+ const bgColor = voiceConfig.processingBackgroundColor ?? originalMicStyles?.backgroundColor ?? "";
100
+ const borderColor = voiceConfig.processingBorderColor ?? originalMicStyles?.borderColor ?? "";
101
+
102
+ removeAllVoiceStateClasses(micButton);
103
+ micButton.classList.add("persona-voice-processing");
104
+ ctx.setMicState("processing");
105
+ micButton.style.backgroundColor = bgColor;
106
+ micButton.style.borderColor = borderColor;
107
+ const resolvedColor = iconColor || "currentColor";
108
+ micButton.style.color = resolvedColor;
109
+ swapMicIcon(micButton, iconName, resolvedColor);
110
+ micButton.setAttribute("aria-label", "Processing voice input");
111
+ // In "none" mode the button is not actionable during processing
112
+ if (interruptionMode === "none") {
113
+ micButton.style.cursor = "default";
114
+ }
115
+ };
116
+
117
+ const speaking = () => {
118
+ const micButton = ctx.mic();
119
+ if (!micButton) return;
120
+ storeOriginalMicStyles(micButton);
121
+ const voiceConfig = ctx.config().voiceRecognition ?? {};
122
+ const interruptionMode = ctx.session().getVoiceInterruptionMode();
123
+ const originalMicStyles = ctx.original;
124
+ // Default icon depends on interruption mode:
125
+ // "square" for cancel, "mic" for barge-in (hot mic), "volume-2" otherwise
126
+ const defaultSpeakingIcon = interruptionMode === "cancel" ? "square"
127
+ : interruptionMode === "barge-in" ? "mic"
128
+ : "volume-2";
129
+ const iconName = voiceConfig.speakingIconName ?? defaultSpeakingIcon;
130
+ const iconColor = voiceConfig.speakingIconColor
131
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingIconColor ?? originalMicStyles?.color ?? "") : (originalMicStyles?.color ?? ""));
132
+ const bgColor = voiceConfig.speakingBackgroundColor
133
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBackgroundColor ?? "var(--persona-voice-recording-bg, #ef4444)") : (originalMicStyles?.backgroundColor ?? ""));
134
+ const borderColor = voiceConfig.speakingBorderColor
135
+ ?? (interruptionMode === "barge-in" ? (voiceConfig.recordingBorderColor ?? "") : (originalMicStyles?.borderColor ?? ""));
136
+
137
+ removeAllVoiceStateClasses(micButton);
138
+ micButton.classList.add("persona-voice-speaking");
139
+ ctx.setMicState("speaking");
140
+ micButton.style.backgroundColor = bgColor;
141
+ micButton.style.borderColor = borderColor;
142
+ const resolvedColor = iconColor || "currentColor";
143
+ micButton.style.color = resolvedColor;
144
+ swapMicIcon(micButton, iconName, resolvedColor);
145
+
146
+ // aria-label varies by interruption mode
147
+ const ariaLabel = interruptionMode === "cancel"
148
+ ? "Stop playback and re-record"
149
+ : interruptionMode === "barge-in"
150
+ ? "Speak to interrupt"
151
+ : "Agent is speaking";
152
+ micButton.setAttribute("aria-label", ariaLabel);
153
+ // In "none" mode the button is not actionable during speaking
154
+ if (interruptionMode === "none") {
155
+ micButton.style.cursor = "default";
156
+ }
157
+ // In "barge-in" mode, add recording class to show mic is hot
158
+ if (interruptionMode === "barge-in") {
159
+ micButton.classList.add("persona-voice-recording");
160
+ }
161
+ };
162
+
163
+ /** Restore mic button to idle state (icon, colors, aria-label, cursor) */
164
+ const reset = () => {
165
+ const micButton = ctx.mic();
166
+ if (!micButton) return;
167
+ removeAllVoiceStateClasses(micButton);
168
+ ctx.setMicState("idle");
169
+ const originalMicStyles = ctx.original;
170
+ if (originalMicStyles) {
171
+ micButton.style.backgroundColor = originalMicStyles.backgroundColor ?? "";
172
+ micButton.style.color = originalMicStyles.color ?? "";
173
+ micButton.style.borderColor = originalMicStyles.borderColor ?? "";
174
+ swapMicIcon(micButton, originalMicStyles.iconName, originalMicStyles.color || "currentColor");
175
+ ctx.original = null;
176
+ }
177
+ micButton.style.cursor = "";
178
+ micButton.setAttribute("aria-label", "Start voice recognition");
179
+ };
180
+
181
+ return { recording, processing, speaking, reset };
182
+ };