@runtypelabs/persona 4.23.2 → 4.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) 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-CI7OckWI.d.cts → types-Db61aI9J.d.cts} +2 -0
  4. package/dist/animations/{types-CI7OckWI.d.ts → types-Db61aI9J.d.ts} +2 -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 +2 -0
  8. package/dist/animations-extra.d.ts +2 -0
  9. package/dist/approval-ui-3NMYNXBD.js +1 -0
  10. package/dist/approval-ui.cjs +2 -2
  11. package/dist/approval-ui.d.cts +278 -37
  12. package/dist/approval-ui.d.ts +278 -37
  13. package/dist/approval-ui.js +2 -2
  14. package/dist/{artifacts-ui-F4N6VMKF.js → artifacts-ui-IGB5YKLO.js} +4 -4
  15. package/dist/artifacts-ui.d.cts +273 -25
  16. package/dist/artifacts-ui.d.ts +273 -25
  17. package/dist/{chunk-NIKYKSNZ.js → chunk-7CO4T5KB.js} +1 -1
  18. package/dist/codegen.cjs +11 -11
  19. package/dist/codegen.js +13 -13
  20. package/dist/context-mentions.d.cts +273 -25
  21. package/dist/context-mentions.d.ts +273 -25
  22. package/dist/event-stream-view.d.cts +273 -25
  23. package/dist/event-stream-view.d.ts +273 -25
  24. package/dist/forms-ui.d.cts +319 -27
  25. package/dist/forms-ui.d.ts +319 -27
  26. package/dist/history-view.d.cts +2 -0
  27. package/dist/history-view.d.ts +2 -0
  28. package/dist/index.cjs +70 -69
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.d.cts +344 -39
  31. package/dist/index.d.ts +344 -39
  32. package/dist/index.global.js +57 -54
  33. package/dist/index.global.js.map +1 -1
  34. package/dist/index.js +70 -69
  35. package/dist/index.js.map +1 -1
  36. package/dist/install.global.js +1 -1
  37. package/dist/install.global.js.map +1 -1
  38. package/dist/launcher.global.js +2 -2
  39. package/dist/launcher.global.js.map +1 -1
  40. package/dist/session-reconnect.d.cts +273 -25
  41. package/dist/session-reconnect.d.ts +273 -25
  42. package/dist/smart-dom-reader.d.cts +273 -25
  43. package/dist/smart-dom-reader.d.ts +273 -25
  44. package/dist/theme-editor-preview.cjs +74 -73
  45. package/dist/theme-editor-preview.d.cts +280 -25
  46. package/dist/theme-editor-preview.d.ts +280 -25
  47. package/dist/theme-editor-preview.js +51 -50
  48. package/dist/theme-editor.cjs +10 -10
  49. package/dist/theme-editor.d.cts +293 -27
  50. package/dist/theme-editor.d.ts +293 -27
  51. package/dist/theme-editor.js +10 -10
  52. package/dist/theme-reference.cjs +1 -1
  53. package/dist/theme-reference.d.cts +1522 -11
  54. package/dist/theme-reference.d.ts +1522 -11
  55. package/dist/theme-reference.js +1 -1
  56. package/dist/voice-runtime-7DA7MJH4.js +1 -0
  57. package/dist/voice-runtime.cjs +1 -1
  58. package/dist/voice-runtime.d.cts +33 -0
  59. package/dist/voice-runtime.d.ts +33 -0
  60. package/dist/voice-runtime.js +1 -1
  61. package/dist/widget.css +1 -1
  62. package/package.json +3 -2
  63. package/src/__fixtures__/defaults-parity.v4.json +776 -0
  64. package/src/approval-ui-entry.ts +0 -1
  65. package/src/approval-ui-loader.ts +5 -4
  66. package/src/approval-ui.ts +0 -1
  67. package/src/client-identity-proof.test.ts +193 -0
  68. package/src/client-session-warm.test.ts +216 -0
  69. package/src/client-visitor-history.test.ts +6 -0
  70. package/src/client.ts +122 -30
  71. package/src/codegen.test.ts +24 -0
  72. package/src/components/activity-row.test.ts +100 -0
  73. package/src/components/activity-row.ts +136 -0
  74. package/src/components/approval-actions.test.ts +8 -5
  75. package/src/components/approval-actions.ts +19 -6
  76. package/src/components/approval-bubble.test.ts +56 -9
  77. package/src/components/approval-bubble.ts +36 -41
  78. package/src/components/composer-builder.ts +5 -2
  79. package/src/components/composer-parts.ts +10 -2
  80. package/src/components/header-builder.test.ts +32 -0
  81. package/src/components/header-builder.ts +7 -4
  82. package/src/components/header-layouts.test.ts +12 -0
  83. package/src/components/header-layouts.ts +3 -2
  84. package/src/components/launcher.ts +11 -2
  85. package/src/components/message-bubble.test.ts +22 -0
  86. package/src/components/message-bubble.ts +7 -3
  87. package/src/components/panel.test.ts +52 -1
  88. package/src/components/panel.ts +47 -8
  89. package/src/components/pill-composer-builder.test.ts +2 -0
  90. package/src/components/pill-composer-builder.ts +0 -1
  91. package/src/components/reasoning-bubble.ts +29 -17
  92. package/src/components/suggestions.test.ts +1 -0
  93. package/src/components/suggestions.ts +0 -16
  94. package/src/components/tool-bubble.ts +131 -114
  95. package/src/components/tool-details.test.ts +87 -0
  96. package/src/components/tool-details.ts +102 -0
  97. package/src/components/welcome.test.ts +8 -0
  98. package/src/components/welcome.ts +4 -2
  99. package/src/defaults-parity.test.ts +158 -0
  100. package/src/defaults.test.ts +69 -0
  101. package/src/defaults.ts +128 -49
  102. package/src/generated/runtype-openapi-contract.ts +2 -2
  103. package/src/index-core.ts +4 -0
  104. package/src/install.test.ts +24 -0
  105. package/src/install.ts +3 -1
  106. package/src/launcher-global.ts +2 -1
  107. package/src/launcher-variants.test.ts +52 -0
  108. package/src/runtime/init-update-reset.test.ts +21 -0
  109. package/src/session.ts +59 -7
  110. package/src/session.voice.test.ts +72 -1
  111. package/src/styles/widget-styles.test.ts +19 -0
  112. package/src/styles/widget.css +295 -23
  113. package/src/theme-editor/index.ts +2 -0
  114. package/src/theme-editor/preview-utils.test.ts +8 -0
  115. package/src/theme-editor/preview-utils.ts +7 -3
  116. package/src/theme-editor/sections.test.ts +31 -0
  117. package/src/theme-editor/sections.ts +55 -1
  118. package/src/theme-editor/state-preferences.test.ts +49 -0
  119. package/src/theme-editor/state.ts +73 -15
  120. package/src/theme-reference.test.ts +20 -0
  121. package/src/theme-reference.ts +11 -5
  122. package/src/tool-call-display-defaults.test.ts +33 -1
  123. package/src/types/theme.ts +80 -3
  124. package/src/types.ts +207 -22
  125. package/src/ui.activity-row.test.ts +91 -0
  126. package/src/ui.approval-plugin.test.ts +63 -0
  127. package/src/ui.composer-compact.test.ts +24 -2
  128. package/src/ui.composer-placement.test.ts +23 -0
  129. package/src/ui.detached-panel.test.ts +33 -6
  130. package/src/ui.docked.test.ts +19 -0
  131. package/src/ui.header-update-stability.test.ts +25 -0
  132. package/src/ui.identity-proof.test.ts +58 -0
  133. package/src/ui.launcher-teaser.test.ts +25 -0
  134. package/src/ui.message-width.test.ts +17 -2
  135. package/src/ui.reasoning-display.test.ts +92 -0
  136. package/src/ui.rebuild-composer.test.ts +20 -3
  137. package/src/ui.scroll.test.ts +32 -0
  138. package/src/ui.session-init.test.ts +312 -0
  139. package/src/ui.status-indicator.test.ts +82 -0
  140. package/src/ui.suggest-replies.test.ts +11 -0
  141. package/src/ui.ts +422 -74
  142. package/src/ui.v5-defaults.test.ts +144 -0
  143. package/src/ui.voice-prewarm.test.ts +222 -0
  144. package/src/ui.welcome.test.ts +92 -3
  145. package/src/utils/__snapshots__/tokens-parity.test.ts.snap +811 -140
  146. package/src/utils/code-generators.ts +14 -0
  147. package/src/utils/composer-compact.test.ts +0 -2
  148. package/src/utils/composer-compact.ts +2 -4
  149. package/src/utils/composer-placement.ts +2 -3
  150. package/src/utils/config-merge.test.ts +81 -5
  151. package/src/utils/config-merge.ts +13 -3
  152. package/src/utils/defaults-provenance.ts +67 -0
  153. package/src/utils/dock.ts +7 -1
  154. package/src/utils/launcher-variant.ts +16 -0
  155. package/src/utils/panel-config.ts +97 -0
  156. package/src/utils/status-indicator.ts +39 -0
  157. package/src/utils/theme.test.ts +182 -0
  158. package/src/utils/theme.ts +131 -18
  159. package/src/utils/tokens.test.ts +122 -1
  160. package/src/utils/tokens.ts +177 -17
  161. package/src/voice/runtype-voice-provider.ts +238 -27
  162. package/src/voice/voice.test.ts +272 -2
  163. package/src/welcome.test.ts +23 -0
  164. package/src/welcome.ts +16 -9
  165. package/dist/approval-ui-K7R5Q5OC.js +0 -1
  166. package/dist/voice-runtime-EFR3R5ES.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { CIRCLE_LAUNCHER_TOKENS } from "./launcher-variant";
1
2
  import type {
2
3
  DeepPartial,
3
4
  PersonaTheme,
@@ -9,8 +10,8 @@ import type {
9
10
  SemanticTokens,
10
11
  } from '../types/theme';
11
12
  import {
12
- DEFAULT_FLOATING_LAUNCHER_MAX_WIDTH,
13
13
  DEFAULT_FLOATING_LAUNCHER_WIDTH,
14
+ resolveDefaultsVersion,
14
15
  } from '../defaults';
15
16
 
16
17
  // Detached/docked panel defaults, shared by the panel token defaults, the alias
@@ -25,7 +26,7 @@ const spacing = (() => {
25
26
  return s as Record<0 | 1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64, string>;
26
27
  })();
27
28
 
28
- export const DEFAULT_PALETTE = {
29
+ const DEFAULT_PALETTE_BASE = {
29
30
  colors: {
30
31
  primary: {
31
32
  50: '#ffffff',
@@ -209,7 +210,7 @@ export const DEFAULT_PALETTE = {
209
210
  },
210
211
  };
211
212
 
212
- export const DEFAULT_SEMANTIC: SemanticTokens = {
213
+ const DEFAULT_SEMANTIC_BASE: SemanticTokens = {
213
214
  colors: {
214
215
  primary: 'palette.colors.primary.500',
215
216
  secondary: 'palette.colors.secondary.500',
@@ -257,7 +258,7 @@ export const DEFAULT_SEMANTIC: SemanticTokens = {
257
258
  },
258
259
  };
259
260
 
260
- export const DEFAULT_COMPONENTS: ComponentTokens = {
261
+ const DEFAULT_COMPONENTS_BASE: ComponentTokens = {
261
262
  button: {
262
263
  primary: {
263
264
  // Primary Actions role: solid primary
@@ -305,13 +306,31 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
305
306
  },
306
307
  panel: {
307
308
  width: DEFAULT_FLOATING_LAUNCHER_WIDTH,
308
- maxWidth: DEFAULT_FLOATING_LAUNCHER_MAX_WIDTH,
309
- height: '600px',
310
- maxHeight: 'calc(100vh - 80px)',
309
+ // Matches the existing floating runtime geometry. `maxWidth` and
310
+ // `maxHeight` intentionally do not impose a second cap; ui.ts applies
311
+ // the responsive width and heightOffset rules for this mode.
312
+ maxWidth: 'none',
313
+ height: 'min(640px, max(200px, calc(100vh - 64px)))',
314
+ maxHeight: 'none',
311
315
  borderRadius: 'palette.radius.xl',
312
316
  shadow: 'palette.shadows.xl',
313
317
  inset: DEFAULT_PANEL_INSET,
314
318
  canvasBackground: DEFAULT_PANEL_CANVAS_BACKGROUND,
319
+ modes: {
320
+ floating: {
321
+ border: '1px solid var(--persona-border)',
322
+ shadow: 'palette.shadows.xl',
323
+ borderRadius: 'palette.radius.xl',
324
+ },
325
+ inline: {
326
+ border: '1px solid var(--persona-border)',
327
+ shadow: 'none',
328
+ borderRadius: 'palette.radius.xl',
329
+ },
330
+ docked: { border: 'none', shadow: 'none', borderRadius: 'palette.radius.xl' },
331
+ sidebar: { border: 'none', borderRadius: '0' },
332
+ mobile: { border: 'none', shadow: 'none', borderRadius: '0' },
333
+ },
315
334
  },
316
335
  header: {
317
336
  // Header role: solid primary. Border, subtitle, and action icons stay
@@ -319,11 +338,13 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
319
338
  background: 'palette.colors.primary.500',
320
339
  foreground: 'palette.colors.primary.50',
321
340
  borderRadius: 'palette.radius.xl palette.radius.xl 0 0',
322
- padding: 'semantic.spacing.md',
341
+ padding: '20px 24px',
342
+ minimalPadding: '16px 24px',
323
343
  iconBackground: 'palette.colors.primary.600',
324
344
  iconForeground: 'palette.colors.primary.50',
325
345
  },
326
346
  message: {
347
+ gap: '12px',
327
348
  user: {
328
349
  // User Messages role: solid primary
329
350
  background: 'palette.colors.primary.500',
@@ -532,6 +553,110 @@ export const DEFAULT_COMPONENTS: ComponentTokens = {
532
553
  },
533
554
  };
534
555
 
556
+ /** Shared defaults with a small opt-in V5 overlay. */
557
+ const DEFAULT_PALETTE_GRAY_V4 = DEFAULT_PALETTE_BASE.colors.gray;
558
+ const DEFAULT_PALETTE_GRAY_V5 = {
559
+ 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4',
560
+ 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040',
561
+ 800: '#262626', 900: '#171717', 950: '#0a0a0a',
562
+ };
563
+
564
+ export const DEFAULT_PALETTE_V4 = {
565
+ ...DEFAULT_PALETTE_BASE,
566
+ colors: { ...DEFAULT_PALETTE_BASE.colors, gray: DEFAULT_PALETTE_GRAY_V4 },
567
+ };
568
+ export const DEFAULT_PALETTE_V5 = {
569
+ ...DEFAULT_PALETTE_BASE,
570
+ colors: { ...DEFAULT_PALETTE_BASE.colors, gray: DEFAULT_PALETTE_GRAY_V5 },
571
+ radius: { none: '0px', sm: '4px', md: '8px', lg: '12px', xl: '16px', '2xl': '24px', full: '9999px' },
572
+ };
573
+ export const DEFAULT_PALETTE = DEFAULT_PALETTE_V4;
574
+
575
+ export const DEFAULT_SEMANTIC_V4 = DEFAULT_SEMANTIC_BASE;
576
+ export const DEFAULT_SEMANTIC_V5: SemanticTokens = {
577
+ ...DEFAULT_SEMANTIC_BASE,
578
+ colors: {
579
+ ...DEFAULT_SEMANTIC_BASE.colors,
580
+ surface: '#ffffff', background: '#ffffff', container: 'semantic.colors.surface',
581
+ textInverse: '#ffffff',
582
+ },
583
+ typography: { ...DEFAULT_SEMANTIC_BASE.typography, fontSize: '14px', lineHeight: '1.5' },
584
+ };
585
+ export const DEFAULT_SEMANTIC = DEFAULT_SEMANTIC_V4;
586
+
587
+ export const DEFAULT_COMPONENTS_V4 = DEFAULT_COMPONENTS_BASE;
588
+ export const DEFAULT_COMPONENTS_V5: ComponentTokens = {
589
+ ...DEFAULT_COMPONENTS_BASE,
590
+ launcher: CIRCLE_LAUNCHER_TOKENS,
591
+ activity: { rowHeight: "32px", labelSize: "13px", iconSize: "16px", indent: "0px", groupIndent: "0px", groupGap: "0px", groupPadding: "0px", transcriptGap: "0px", responseGap: "12px", bodyPadding: "4px 0", bodySize: "13px", bodyLineHeight: "1.5", bodyFontWeight: "400", sectionGap: "8px", bodySurface: "transparent" },
592
+ toolBubble: { shadow: "none" },
593
+ reasoningBubble: { shadow: "none" },
594
+ input: { ...DEFAULT_COMPONENTS_BASE.input, borderRadius: 'palette.radius.full' },
595
+ introCard: { ...DEFAULT_COMPONENTS_BASE.introCard, title: { fontSize: '22px', fontWeight: '500' } },
596
+ suggestion: {
597
+ chip: { ...DEFAULT_COMPONENTS_BASE.suggestion!.chip, fontWeight: '400' },
598
+ card: { ...DEFAULT_COMPONENTS_BASE.suggestion!.card, fontWeight: '400' },
599
+ list: { ...DEFAULT_COMPONENTS_BASE.suggestion!.list, fontWeight: '400' },
600
+ },
601
+ panel: {
602
+ ...DEFAULT_COMPONENTS_BASE.panel,
603
+ width: 'min(400px, calc(100vw - 24px))',
604
+ height: 'min(704px, calc(100dvh - 104px))',
605
+ borderRadius: 'palette.radius.xl',
606
+ },
607
+ header: {
608
+ ...DEFAULT_COMPONENTS_BASE.header,
609
+ background: 'semantic.colors.container', foreground: 'semantic.colors.text',
610
+ padding: '8px 8px 8px 16px', minimalPadding: '8px 8px 8px 16px',
611
+ minHeight: '48px', borderBottom: 'none',
612
+ iconBackground: 'transparent', iconForeground: 'semantic.colors.text', iconScale: '1',
613
+ controlSize: '28px', controlIconSize: '18px', controlStrokeWidth: '1.5',
614
+ actionIconForeground: 'semantic.colors.textMuted',
615
+ controlBorderRadius: '8px',
616
+ controlHoverBackground: 'color-mix(in srgb, var(--persona-text) 8%, transparent)',
617
+ controlHoverForeground: 'semantic.colors.text',
618
+ controlFocusOutline: '2px solid color-mix(in srgb, var(--persona-text) 50%, transparent)',
619
+ title: { fontSize: '14px', fontWeight: '500', lineHeight: '1.5' },
620
+ },
621
+ message: {
622
+ ...DEFAULT_COMPONENTS_BASE.message,
623
+ gap: '20px', fullscreenGap: '28px', topFadeHeight: '18px',
624
+ user: {
625
+ ...DEFAULT_COMPONENTS_BASE.message.user,
626
+ background: 'palette.colors.gray.100', text: 'semantic.colors.text',
627
+ borderRadius: 'palette.radius.xl', shadow: 'none', padding: '8px 14px',
628
+ fontSize: '14px', lineHeight: '1.5',
629
+ },
630
+ assistant: {
631
+ ...DEFAULT_COMPONENTS_BASE.message.assistant,
632
+ background: 'transparent', border: 'transparent', borderWidth: '0px',
633
+ borderRadius: '0px', shadow: 'none', padding: '0px',
634
+ fontSize: '14px', lineHeight: '1.55',
635
+ },
636
+ },
637
+ composer: {
638
+ ...DEFAULT_COMPONENTS_BASE.composer,
639
+ padding: '8px', fontSize: '15px', lineHeight: '1.5', controlSize: '32px', controlIconSize: '20px',
640
+ sendIconSize: '18px', sendButtonRadius: '9999px', footerBorder: 'none',
641
+ overlayBand: 'linear-gradient(to bottom, transparent, var(--persona-container) 24px)',
642
+ focusBorderColor: 'color-mix(in srgb, var(--persona-text) 28%, var(--persona-border))',
643
+ focusRing: '1px solid color-mix(in srgb, var(--persona-text) 12%, transparent)',
644
+ },
645
+ };
646
+ export const DEFAULT_COMPONENTS = DEFAULT_COMPONENTS_V4;
647
+
648
+ export type ThemeDefaultsVersion = 'v4' | 'v5';
649
+
650
+ export const resolveThemeDefaultsVersion = (future?: { v5Defaults?: boolean }): ThemeDefaultsVersion =>
651
+ resolveDefaultsVersion({ future });
652
+
653
+ export const resolveThemeDefaults = (future?: { v5Defaults?: boolean }) => {
654
+ const version = resolveThemeDefaultsVersion(future);
655
+ return version === 'v5'
656
+ ? { palette: DEFAULT_PALETTE_V5, semantic: DEFAULT_SEMANTIC_V5, components: DEFAULT_COMPONENTS_V5 }
657
+ : { palette: DEFAULT_PALETTE_V4, semantic: DEFAULT_SEMANTIC_V4, components: DEFAULT_COMPONENTS_V4 };
658
+ };
659
+
535
660
  export function resolveTokenValue(theme: PersonaTheme, path: string): string | undefined {
536
661
  if (
537
662
  !path.startsWith('palette.') &&
@@ -673,10 +798,11 @@ export function createTheme(
673
798
  userConfig?: DeepPartial<PersonaTheme>,
674
799
  options: CreateThemeOptions = {}
675
800
  ): PersonaTheme {
801
+ const defaults = resolveThemeDefaults(options.future);
676
802
  const baseTheme: PersonaTheme = {
677
- palette: DEFAULT_PALETTE as PersonaTheme['palette'],
678
- semantic: DEFAULT_SEMANTIC as PersonaTheme['semantic'],
679
- components: DEFAULT_COMPONENTS as PersonaTheme['components'],
803
+ palette: defaults.palette as PersonaTheme['palette'],
804
+ semantic: defaults.semantic as PersonaTheme['semantic'],
805
+ components: defaults.components as PersonaTheme['components'],
680
806
  };
681
807
 
682
808
  let theme: PersonaTheme = {
@@ -738,6 +864,30 @@ export function createTheme(
738
864
  ),
739
865
  } as PersonaTheme;
740
866
 
867
+ // A shared header padding is also the minimal-layout override unless the
868
+ // host supplies the more specific token. Both keys remain emitted CSS vars,
869
+ // so live updates and scheme changes restyle existing header DOM in place.
870
+ const activity = userConfig?.components?.activity;
871
+ for (const kind of ['toolBubble', 'reasoningBubble'] as const) {
872
+ for (const field of ['rowHeight', 'labelSize', 'iconSize', 'indent', 'bodySurface'] as const) {
873
+ if (activity?.[field] !== undefined) theme.components[kind][field] = activity[field];
874
+ }
875
+ }
876
+ const legacySurface = userConfig?.components?.collapsibleWidget?.surface;
877
+ if (legacySurface !== undefined && activity?.bodySurface === undefined) {
878
+ theme.components.activity = { ...theme.components.activity, bodySurface: legacySurface };
879
+ }
880
+
881
+ const suppliedHeader = userConfig?.components?.header;
882
+ if (suppliedHeader?.padding !== undefined && suppliedHeader.minimalPadding === undefined) {
883
+ theme.components.header.minimalPadding = theme.components.header.padding;
884
+ }
885
+
886
+ const suppliedMessage = userConfig?.components?.message;
887
+ if (suppliedMessage?.gap !== undefined && suppliedMessage.fullscreenGap === undefined) {
888
+ theme.components.message.fullscreenGap = theme.components.message.gap;
889
+ }
890
+
741
891
  if (options.validate !== false) {
742
892
  const validation = validateTheme(theme);
743
893
  if (!validation.valid) {
@@ -1224,12 +1374,21 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
1224
1374
  ]);
1225
1375
 
1226
1376
  // Collapsible widget chrome (tool/reasoning/approval bubbles)
1227
- cssVars['--cw-container'] =
1228
- cssVars['--persona-components-collapsibleWidget-container'] ?? cssVars['--persona-surface'];
1229
- cssVars['--cw-surface'] =
1230
- cssVars['--persona-components-collapsibleWidget-surface'] ?? cssVars['--persona-surface'];
1231
- cssVars['--cw-border'] =
1232
- cssVars['--persona-components-collapsibleWidget-border'] ?? cssVars['--persona-border'];
1377
+ const collapsibleWidgetAliases = [
1378
+ ['container', 'surface'],
1379
+ ['surface', 'surface'],
1380
+ ['border', 'border'],
1381
+ ] as const;
1382
+ for (const [name, semanticFallback] of collapsibleWidgetAliases) {
1383
+ const value =
1384
+ cssVars[`--persona-components-collapsibleWidget-${name}`] ??
1385
+ cssVars[`--persona-${semanticFallback}`];
1386
+ if (value !== undefined) {
1387
+ cssVars[`--persona-cw-${name}`] = value;
1388
+ // Kept for custom styles that adopted the original unprefixed aliases.
1389
+ cssVars[`--cw-${name}`] = value;
1390
+ }
1391
+ }
1233
1392
 
1234
1393
  emitAliases(cssVars, [
1235
1394
  ['message-border', 'components-message-border', 'border'],
@@ -1246,6 +1405,7 @@ export function themeToCssVariables(theme: PersonaTheme): Record<string, string>
1246
1405
  ['message-user-font-family', 'components-message-user-fontFamily'],
1247
1406
  ['message-user-line-height', 'components-message-user-lineHeight'],
1248
1407
  ['message-assistant-padding', 'components-message-assistant-padding'],
1408
+ ['message-assistant-border-width', 'components-message-assistant-borderWidth'],
1249
1409
  ['message-assistant-max-width', 'components-message-assistant-maxWidth'],
1250
1410
  ['message-assistant-font-size', 'components-message-assistant-fontSize'],
1251
1411
  ['message-assistant-font-family', 'components-message-assistant-fontFamily'],
@@ -16,6 +16,14 @@
16
16
  // A continuous always-hot mic is, in UX terms, a permanent barge-in session, so
17
17
  // `getInterruptionMode()` reports the constant `'barge-in'` and the existing
18
18
  // mic-button wiring (ui.ts) treats a click as "hang up at any state" unchanged.
19
+ //
20
+ // Prewarm (`prewarm()`, called on mic intent): by default a fire-and-forget
21
+ // `POST /v1/client/agents/:agentId/voice/prewarm`. With `prewarmMode: 'attach'`
22
+ // the WebSocket opens early with `clientCapabilities=attach` and the extra
23
+ // `runtype.attach` subprotocol. If the server negotiates `runtype.attach`, the
24
+ // socket idles (no call) until `startListening()` sends `{"type":"start"}`; if
25
+ // it negotiates plain `runtype.bearer`, it ignored attach and the call is
26
+ // already live, so `startListening()` adopts it as is.
19
27
 
20
28
  import type {
21
29
  VoiceProvider,
@@ -37,6 +45,29 @@ const CAPTURE_BUFFER_SIZE = 4096;
37
45
  */
38
46
  const LEVEL_RMS_SCALE = 4;
39
47
  const RIFF_MAGIC = 0x52494646; // "RIFF"
48
+ /** `prewarm()` warms at most once per this window per provider instance. */
49
+ const PREWARM_THROTTLE_MS = 30_000;
50
+ /** How long `startListening()` waits on an attach still handshaking. */
51
+ const ATTACH_HANDSHAKE_TIMEOUT_MS = 5_000;
52
+ /** Attached-socket idle window: server default and clamp range. */
53
+ const ATTACH_IDLE_DEFAULT_MS = 30_000;
54
+ const ATTACH_IDLE_MIN_MS = 30_000;
55
+ const ATTACH_IDLE_MAX_MS = 600_000;
56
+
57
+ /** A socket opened by an `'attach'` prewarm, waiting for the click. */
58
+ type AttachedSocket = {
59
+ ws: WebSocket;
60
+ /**
61
+ * `connecting` until the handshake completes; then `attached` (no call yet,
62
+ * needs `start`) or `live` (the server ignored attach and the call is live).
63
+ */
64
+ state: "connecting" | "attached" | "live";
65
+ /** Settles when the handshake completes, fails, or the socket is released. */
66
+ ready: Promise<void>;
67
+ settle: () => void;
68
+ openedAt: number;
69
+ idleTimer: ReturnType<typeof setTimeout> | undefined;
70
+ };
40
71
 
41
72
  /**
42
73
  * Strip the canonical 44-byte WAV header (if present) and return the raw PCM16
@@ -63,6 +94,16 @@ function toWsBase(host: string): string {
63
94
  return `${secure ? "wss:" : "ws:"}//${trimmed}`;
64
95
  }
65
96
 
97
+ /** Derive an http(s):// base URL from a configured host (the ws base, reversed). */
98
+ function toHttpBase(host: string): string {
99
+ return toWsBase(host).replace(/^ws/i, "http");
100
+ }
101
+
102
+ function clampAttachIdleMs(ms: number | undefined): number {
103
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return ATTACH_IDLE_DEFAULT_MS;
104
+ return Math.min(ATTACH_IDLE_MAX_MS, Math.max(ATTACH_IDLE_MIN_MS, Math.round(ms)));
105
+ }
106
+
66
107
  export class RuntypeVoiceProvider implements VoiceProvider {
67
108
  type: "runtype" = "runtype";
68
109
 
@@ -87,6 +128,10 @@ export class RuntypeVoiceProvider implements VoiceProvider {
87
128
  // Distinguishes a user-initiated close (code 1000) from a dropped connection.
88
129
  private intentionalClose = false;
89
130
 
131
+ // Prewarm latch and the (at most one) socket an `'attach'` prewarm opened.
132
+ private lastPrewarmAt = Number.NEGATIVE_INFINITY;
133
+ private attached: AttachedSocket | null = null;
134
+
90
135
  private resultCallbacks: ((result: VoiceResult) => void)[] = [];
91
136
  private errorCallbacks: ((error: Error) => void)[] = [];
92
137
  private statusCallbacks: ((status: VoiceStatus) => void)[] = [];
@@ -104,6 +149,153 @@ export class RuntypeVoiceProvider implements VoiceProvider {
104
149
  /** No-op: the WS session opens lazily in `startListening` (the "call"). */
105
150
  async connect(): Promise<void> {}
106
151
 
152
+ /**
153
+ * Warm the voice path ahead of the click. Fire-and-forget and throttled to
154
+ * once per {@link PREWARM_THROTTLE_MS}; never emits an error or a status.
155
+ */
156
+ prewarm(): void {
157
+ if (this.callLive || this.attached) return;
158
+ const agentId = this.config?.agentId;
159
+ const token = this.config?.clientToken;
160
+ const host = this.config?.host;
161
+ if (!agentId || !token || !host) return;
162
+ const now = Date.now();
163
+ if (now - this.lastPrewarmAt < PREWARM_THROTTLE_MS) return;
164
+ this.lastPrewarmAt = now;
165
+ try {
166
+ if (this.config?.prewarmMode === "attach") {
167
+ this.attach(host, agentId, token);
168
+ } else {
169
+ this.requestPrewarm(host, agentId, token);
170
+ }
171
+ } catch {
172
+ // A prewarm must never surface an error; the click connects normally.
173
+ }
174
+ }
175
+
176
+ private requestPrewarm(host: string, agentId: string, token: string): void {
177
+ if (typeof fetch !== "function") return;
178
+ const url = `${toHttpBase(host)}/v1/client/agents/${encodeURIComponent(agentId)}/voice/prewarm`;
179
+ void fetch(url, {
180
+ method: "POST",
181
+ headers: { Authorization: `Bearer ${token}` },
182
+ keepalive: true,
183
+ }).catch(() => {});
184
+ }
185
+
186
+ private attach(host: string, agentId: string, token: string): void {
187
+ const params = new URLSearchParams({ clientCapabilities: "attach" });
188
+ const configuredIdleMs = this.config?.attachIdleMs;
189
+ const idleMs = clampAttachIdleMs(configuredIdleMs);
190
+ if (configuredIdleMs !== undefined) params.set("attachIdleMs", String(idleMs));
191
+ const ws = new WebSocket(`${this.voiceSocketUrl(host, agentId)}?${params}`, [
192
+ "runtype.bearer",
193
+ "runtype.attach",
194
+ token,
195
+ ]);
196
+ ws.binaryType = "arraybuffer";
197
+ let settle: () => void = () => {};
198
+ const ready = new Promise<void>((resolve) => {
199
+ settle = resolve;
200
+ });
201
+ const entry: AttachedSocket = {
202
+ ws,
203
+ state: "connecting",
204
+ ready,
205
+ settle,
206
+ openedAt: 0,
207
+ idleTimer: undefined,
208
+ };
209
+ this.attached = entry;
210
+
211
+ ws.onopen = () => {
212
+ if (this.attached !== entry) return;
213
+ entry.state = ws.protocol === "runtype.attach" ? "attached" : "live";
214
+ entry.openedAt = Date.now();
215
+ this.armAttachIdle(entry, idleMs);
216
+ if (entry.state === "attached") ws.send('{"type":"ping"}');
217
+ entry.settle();
218
+ };
219
+ ws.onmessage = (event) => {
220
+ if (this.attached !== entry || typeof event.data !== "string") return;
221
+ let msg: { type?: unknown; idleMs?: unknown };
222
+ try {
223
+ msg = JSON.parse(event.data);
224
+ } catch {
225
+ return;
226
+ }
227
+ // The server's idle window is authoritative; close no later than it does.
228
+ if (msg.type === "attached" && typeof msg.idleMs === "number") {
229
+ this.armAttachIdle(entry, msg.idleMs - (Date.now() - entry.openedAt));
230
+ }
231
+ };
232
+ ws.onerror = () => {};
233
+ ws.onclose = () => {
234
+ if (this.attached === entry) this.releaseAttached();
235
+ };
236
+ }
237
+
238
+ private armAttachIdle(entry: AttachedSocket, ms: number): void {
239
+ clearTimeout(entry.idleTimer);
240
+ entry.idleTimer = setTimeout(() => {
241
+ if (this.attached === entry) this.releaseAttached();
242
+ }, Math.max(0, ms));
243
+ }
244
+
245
+ /** Close and forget the prewarmed socket, if any. Silent by design. */
246
+ private releaseAttached(): void {
247
+ const entry = this.attached;
248
+ if (!entry) return;
249
+ this.attached = null;
250
+ clearTimeout(entry.idleTimer);
251
+ const { ws } = entry;
252
+ ws.onopen = null;
253
+ ws.onmessage = null;
254
+ ws.onerror = null;
255
+ ws.onclose = null;
256
+ entry.settle();
257
+ if (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN) {
258
+ try {
259
+ ws.close(1000, "prewarm released");
260
+ } catch {
261
+ // ignore
262
+ }
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Hand the prewarmed socket to the call, waiting up to
268
+ * {@link ATTACH_HANDSHAKE_TIMEOUT_MS} for a handshake still in flight.
269
+ * Resolves `null` when there is none, it stalled, or it dropped, so the
270
+ * caller opens a fresh socket.
271
+ */
272
+ private async takeAttachedSocket(): Promise<{ ws: WebSocket; live: boolean } | null> {
273
+ const entry = this.attached;
274
+ if (!entry) return null;
275
+ if (entry.state === "connecting") {
276
+ let timer: ReturnType<typeof setTimeout> | undefined;
277
+ await Promise.race([
278
+ entry.ready,
279
+ new Promise<void>((resolve) => {
280
+ timer = setTimeout(resolve, ATTACH_HANDSHAKE_TIMEOUT_MS);
281
+ }),
282
+ ]);
283
+ clearTimeout(timer);
284
+ }
285
+ if (this.attached !== entry) return null;
286
+ if (entry.state === "connecting" || entry.ws.readyState !== WebSocket.OPEN) {
287
+ this.releaseAttached();
288
+ return null;
289
+ }
290
+ this.attached = null;
291
+ clearTimeout(entry.idleTimer);
292
+ return { ws: entry.ws, live: entry.state === "live" };
293
+ }
294
+
295
+ private voiceSocketUrl(host: string, agentId: string): string {
296
+ return `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
297
+ }
298
+
107
299
  /** Start the call: acquire mic, open the WS, stream PCM until hang-up. */
108
300
  async startListening(): Promise<void> {
109
301
  if (this.callLive) return; // idempotent: a call is already live
@@ -164,11 +356,25 @@ export class RuntypeVoiceProvider implements VoiceProvider {
164
356
  }
165
357
  });
166
358
 
167
- const wsUrl = `${toWsBase(host)}/ws/agents/${encodeURIComponent(agentId)}/voice`;
359
+ const adopted = await this.takeAttachedSocket();
360
+ if (generation !== this.callGeneration) {
361
+ adopted?.ws.close(1000, "client ended call");
362
+ return;
363
+ }
364
+ if (adopted) {
365
+ const { ws } = adopted;
366
+ this.ws = ws;
367
+ this.bindCallSocket(ws, generation);
368
+ if (!adopted.live) ws.send('{"type":"start"}');
369
+ this.emitStatus("listening");
370
+ this.startCapture(captureContext, stream, ws, generation);
371
+ return;
372
+ }
373
+
168
374
  // Token rides the subprotocol; `runtype.bearer` is the marker the server
169
375
  // echoes as the negotiated subprotocol (browsers fail the handshake if an
170
376
  // offered subprotocol goes unanswered).
171
- const ws = new WebSocket(wsUrl, ["runtype.bearer", token]);
377
+ const ws = new WebSocket(this.voiceSocketUrl(host, agentId), ["runtype.bearer", token]);
172
378
  ws.binaryType = "arraybuffer";
173
379
  this.ws = ws;
174
380
 
@@ -177,31 +383,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
177
383
  this.emitStatus("listening");
178
384
  this.startCapture(captureContext, stream, ws, generation);
179
385
  };
180
-
181
- ws.onmessage = (event) => this.handleMessage(event, generation);
182
-
183
- ws.onerror = () => {
184
- if (generation !== this.callGeneration) return;
185
- this.emitError(new Error("Voice connection failed"));
186
- this.emitStatus("error");
187
- this.cleanup();
188
- };
189
-
190
- ws.onclose = (evt) => {
191
- if (this.intentionalClose) {
192
- this.intentionalClose = false;
193
- return;
194
- }
195
- if (generation !== this.callGeneration) return;
196
- if (evt.code !== 1000) {
197
- const codeMsg = evt.code ? ` (code ${evt.code})` : "";
198
- this.emitError(new Error(`Voice connection closed${codeMsg}`));
199
- this.emitStatus("error");
200
- } else {
201
- this.emitStatus("idle");
202
- }
203
- this.cleanup();
204
- };
386
+ this.bindCallSocket(ws, generation);
205
387
  } catch (error) {
206
388
  this.cleanup();
207
389
  this.emitError(error as Error);
@@ -210,6 +392,34 @@ export class RuntypeVoiceProvider implements VoiceProvider {
210
392
  }
211
393
  }
212
394
 
395
+ /** Route a call socket's frames, errors, and close into this call. */
396
+ private bindCallSocket(ws: WebSocket, generation: number): void {
397
+ ws.onmessage = (event) => this.handleMessage(event, generation);
398
+
399
+ ws.onerror = () => {
400
+ if (generation !== this.callGeneration) return;
401
+ this.emitError(new Error("Voice connection failed"));
402
+ this.emitStatus("error");
403
+ this.cleanup();
404
+ };
405
+
406
+ ws.onclose = (evt) => {
407
+ if (this.intentionalClose) {
408
+ this.intentionalClose = false;
409
+ return;
410
+ }
411
+ if (generation !== this.callGeneration) return;
412
+ if (evt.code !== 1000) {
413
+ const codeMsg = evt.code ? ` (code ${evt.code})` : "";
414
+ this.emitError(new Error(`Voice connection closed${codeMsg}`));
415
+ this.emitStatus("error");
416
+ } else {
417
+ this.emitStatus("idle");
418
+ }
419
+ this.cleanup();
420
+ };
421
+ }
422
+
213
423
  /** End the call (hang up). */
214
424
  async stopListening(): Promise<void> {
215
425
  this.cleanup();
@@ -374,6 +584,7 @@ export class RuntypeVoiceProvider implements VoiceProvider {
374
584
  this.callGeneration += 1;
375
585
  this.callLive = false;
376
586
  this.isSpeaking = false;
587
+ this.releaseAttached();
377
588
 
378
589
  if (this.processor) {
379
590
  this.processor.onaudioprocess = null;