bosun 0.37.0 → 0.37.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 (43) hide show
  1. package/.env.example +4 -1
  2. package/agent-tool-config.mjs +338 -0
  3. package/bosun-skills.mjs +59 -4
  4. package/bosun.schema.json +1 -1
  5. package/desktop/launch.mjs +18 -0
  6. package/desktop/main.mjs +52 -13
  7. package/fleet-coordinator.mjs +34 -1
  8. package/kanban-adapter.mjs +30 -3
  9. package/library-manager.mjs +66 -0
  10. package/maintenance.mjs +30 -5
  11. package/monitor.mjs +56 -0
  12. package/package.json +4 -1
  13. package/setup-web-server.mjs +73 -12
  14. package/setup.mjs +3 -3
  15. package/ui/app.js +40 -3
  16. package/ui/components/session-list.js +25 -7
  17. package/ui/components/workspace-switcher.js +48 -1
  18. package/ui/demo.html +176 -0
  19. package/ui/modules/mic-track-registry.js +83 -0
  20. package/ui/modules/settings-schema.js +4 -1
  21. package/ui/modules/state.js +25 -0
  22. package/ui/modules/streaming.js +1 -1
  23. package/ui/modules/voice-barge-in.js +27 -0
  24. package/ui/modules/voice-client-sdk.js +268 -42
  25. package/ui/modules/voice-client.js +665 -61
  26. package/ui/modules/voice-overlay.js +829 -47
  27. package/ui/setup.html +151 -9
  28. package/ui/styles.css +258 -0
  29. package/ui/tabs/chat.js +11 -0
  30. package/ui/tabs/library.js +890 -15
  31. package/ui/tabs/settings.js +51 -11
  32. package/ui/tabs/telemetry.js +327 -105
  33. package/ui/tabs/workflows.js +86 -0
  34. package/ui-server.mjs +1201 -107
  35. package/voice-action-dispatcher.mjs +81 -0
  36. package/voice-agents-sdk.mjs +2 -2
  37. package/voice-relay.mjs +131 -14
  38. package/voice-tools.mjs +475 -9
  39. package/workflow-engine.mjs +54 -0
  40. package/workflow-nodes.mjs +177 -28
  41. package/workflow-templates/github.mjs +205 -94
  42. package/workflow-templates/task-batch.mjs +247 -0
  43. package/workflow-templates.mjs +15 -0
@@ -19,12 +19,15 @@ import {
19
19
  voiceToolCalls, voiceDuration, isVoiceMicMuted,
20
20
  startVoiceSession, stopVoiceSession, interruptResponse,
21
21
  sendTextMessage, sendImageFrame, onVoiceEvent, resumeVoiceAudio, toggleMicMute,
22
+ audioInputDevices, audioOutputDevices, selectedAudioInput, selectedAudioOutput,
23
+ micInputLevel, audioSettings,
24
+ enumerateAudioDevices, switchAudioInput, switchAudioOutput, updateAudioSettings,
22
25
  } from "./voice-client.js";
23
26
  import {
24
27
  sdkVoiceState, sdkVoiceTranscript, sdkVoiceResponse, sdkVoiceError,
25
28
  sdkVoiceToolCalls, sdkVoiceDuration, sdkVoiceSdkActive,
26
29
  startSdkVoiceSession, stopSdkVoiceSession, interruptSdkResponse,
27
- sendSdkTextMessage, sendSdkImageFrame, onSdkVoiceEvent,
30
+ sendSdkTextMessage, sendSdkImageFrame, onSdkVoiceEvent, toggleSdkMicMute,
28
31
  } from "./voice-client-sdk.js";
29
32
  import {
30
33
  fallbackState, fallbackTranscript, fallbackResponse,
@@ -561,6 +564,319 @@ function injectOverlayStyles() {
561
564
  }
562
565
  .voice-overlay-chat-live.user { border-color: rgba(138,180,248,0.4); }
563
566
  .voice-overlay-chat-live.assistant { border-color: rgba(129,201,149,0.4); }
567
+
568
+ /* ── Device Picker Dropdown ────────────────────────────────────── */
569
+ .vm-device-picker-anchor {
570
+ position: relative;
571
+ display: inline-flex;
572
+ }
573
+ .vm-device-picker {
574
+ position: absolute;
575
+ bottom: calc(100% + 8px);
576
+ left: 50%;
577
+ transform: translateX(-50%);
578
+ min-width: 320px;
579
+ max-width: 400px;
580
+ background: #2d2e30;
581
+ border: 1px solid rgba(255,255,255,0.14);
582
+ border-radius: 12px;
583
+ box-shadow: 0 12px 48px rgba(0,0,0,0.7), 0 0 0 1px rgba(255,255,255,0.06);
584
+ z-index: 100;
585
+ overflow: hidden;
586
+ animation: vmPickerSlideUp 0.18s ease;
587
+ max-height: 60vh;
588
+ overflow-y: auto;
589
+ }
590
+ @keyframes vmPickerSlideUp {
591
+ from { opacity: 0; transform: translateX(-50%) translateY(8px); }
592
+ to { opacity: 1; transform: translateX(-50%) translateY(0); }
593
+ }
594
+ .vm-device-picker-section {
595
+ padding: 6px 0;
596
+ border-bottom: 1px solid rgba(255,255,255,0.08);
597
+ }
598
+ .vm-device-picker-section:last-child { border-bottom: none; }
599
+ .vm-device-picker-label {
600
+ font-size: 11px;
601
+ font-weight: 600;
602
+ text-transform: uppercase;
603
+ letter-spacing: 0.04em;
604
+ color: rgba(255,255,255,0.5);
605
+ padding: 6px 16px 4px;
606
+ }
607
+ .vm-device-item {
608
+ display: flex;
609
+ align-items: center;
610
+ gap: 10px;
611
+ padding: 8px 16px;
612
+ cursor: pointer;
613
+ transition: background 0.1s;
614
+ border: none;
615
+ background: none;
616
+ color: #e8eaed;
617
+ font-size: 13px;
618
+ width: 100%;
619
+ text-align: left;
620
+ }
621
+ .vm-device-item:hover { background: rgba(255,255,255,0.06); }
622
+ .vm-device-item.active { color: #8ab4f8; }
623
+ .vm-device-check {
624
+ width: 20px;
625
+ height: 20px;
626
+ border-radius: 50%;
627
+ display: flex;
628
+ align-items: center;
629
+ justify-content: center;
630
+ flex-shrink: 0;
631
+ font-size: 14px;
632
+ }
633
+ .vm-device-check.selected {
634
+ background: #8ab4f8;
635
+ color: #202124;
636
+ }
637
+ .vm-device-name {
638
+ flex: 1;
639
+ min-width: 0;
640
+ overflow: hidden;
641
+ text-overflow: ellipsis;
642
+ white-space: nowrap;
643
+ }
644
+ .vm-device-default {
645
+ font-size: 11px;
646
+ color: rgba(255,255,255,0.45);
647
+ margin-top: 1px;
648
+ }
649
+ .vm-mic-level-bar {
650
+ height: 4px;
651
+ border-radius: 2px;
652
+ background: #3c4043;
653
+ overflow: hidden;
654
+ margin: 4px 16px 8px;
655
+ }
656
+ .vm-mic-level-fill {
657
+ height: 100%;
658
+ border-radius: 2px;
659
+ background: #8ab4f8;
660
+ transition: width 0.1s linear;
661
+ }
662
+
663
+ /* ── Settings Panel ─────────────────────────────────────────────── */
664
+ .vm-settings-overlay {
665
+ position: absolute;
666
+ inset: 0;
667
+ background: rgba(0,0,0,0.6);
668
+ z-index: 50;
669
+ display: flex;
670
+ align-items: center;
671
+ justify-content: center;
672
+ animation: voiceOverlayFadeIn 0.15s ease;
673
+ }
674
+ .vm-settings-panel {
675
+ width: min(500px, 90vw);
676
+ max-height: 80vh;
677
+ background: #2d2e30;
678
+ border-radius: 16px;
679
+ box-shadow: 0 16px 60px rgba(0,0,0,0.6);
680
+ overflow-y: auto;
681
+ animation: vmPickerSlideUp 0.2s ease;
682
+ }
683
+ .vm-settings-header {
684
+ display: flex;
685
+ align-items: center;
686
+ justify-content: space-between;
687
+ padding: 16px 20px 12px;
688
+ border-bottom: 1px solid rgba(255,255,255,0.1);
689
+ }
690
+ .vm-settings-title {
691
+ font-size: 16px;
692
+ font-weight: 600;
693
+ color: #e8eaed;
694
+ }
695
+ .vm-settings-close {
696
+ width: 32px; height: 32px; border-radius: 50%;
697
+ border: none; background: rgba(255,255,255,0.08);
698
+ color: #e8eaed; font-size: 16px; cursor: pointer;
699
+ display: flex; align-items: center; justify-content: center;
700
+ transition: background 0.15s;
701
+ }
702
+ .vm-settings-close:hover { background: rgba(255,255,255,0.14); }
703
+ .vm-settings-body { padding: 12px 20px 20px; }
704
+ .vm-settings-section {
705
+ margin-bottom: 16px;
706
+ }
707
+ .vm-settings-section-title {
708
+ font-size: 13px;
709
+ font-weight: 600;
710
+ color: rgba(255,255,255,0.85);
711
+ margin-bottom: 8px;
712
+ display: flex;
713
+ align-items: center;
714
+ gap: 8px;
715
+ }
716
+ .vm-settings-row {
717
+ display: flex;
718
+ align-items: center;
719
+ justify-content: space-between;
720
+ padding: 8px 0;
721
+ gap: 12px;
722
+ }
723
+ .vm-settings-row-info { flex: 1; min-width: 0; }
724
+ .vm-settings-row-label {
725
+ font-size: 13px;
726
+ color: #e8eaed;
727
+ }
728
+ .vm-settings-row-desc {
729
+ font-size: 11px;
730
+ color: rgba(255,255,255,0.5);
731
+ margin-top: 2px;
732
+ }
733
+ .vm-settings-toggle {
734
+ position: relative;
735
+ width: 44px;
736
+ height: 24px;
737
+ border-radius: 12px;
738
+ border: none;
739
+ background: #5f6368;
740
+ cursor: pointer;
741
+ flex-shrink: 0;
742
+ transition: background 0.2s;
743
+ }
744
+ .vm-settings-toggle.on { background: #8ab4f8; }
745
+ .vm-settings-toggle::after {
746
+ content: '';
747
+ position: absolute;
748
+ top: 3px;
749
+ left: 3px;
750
+ width: 18px;
751
+ height: 18px;
752
+ border-radius: 50%;
753
+ background: #fff;
754
+ transition: transform 0.2s;
755
+ }
756
+ .vm-settings-toggle.on::after { transform: translateX(20px); }
757
+ .vm-settings-select {
758
+ background: #3c4043;
759
+ border: 1px solid rgba(255,255,255,0.15);
760
+ border-radius: 8px;
761
+ color: #e8eaed;
762
+ padding: 6px 10px;
763
+ font-size: 13px;
764
+ min-width: 160px;
765
+ cursor: pointer;
766
+ }
767
+ .vm-settings-select:focus { outline: none; border-color: #8ab4f8; }
768
+
769
+ /* ── Three-dot menu ─────────────────────────────────────────────── */
770
+ .vm-more-menu {
771
+ position: absolute;
772
+ bottom: calc(100% + 8px);
773
+ right: 0;
774
+ min-width: 260px;
775
+ background: #2d2e30;
776
+ border: 1px solid rgba(255,255,255,0.14);
777
+ border-radius: 12px;
778
+ box-shadow: 0 12px 48px rgba(0,0,0,0.7);
779
+ z-index: 100;
780
+ overflow: hidden;
781
+ animation: vmPickerSlideUp 0.18s ease;
782
+ padding: 6px 0;
783
+ }
784
+ .vm-more-item {
785
+ display: flex;
786
+ align-items: center;
787
+ gap: 14px;
788
+ padding: 10px 16px;
789
+ cursor: pointer;
790
+ border: none;
791
+ background: none;
792
+ color: #e8eaed;
793
+ font-size: 13px;
794
+ width: 100%;
795
+ text-align: left;
796
+ transition: background 0.1s;
797
+ }
798
+ .vm-more-item:hover { background: rgba(255,255,255,0.06); }
799
+ .vm-more-item.disabled {
800
+ opacity: 0.4;
801
+ cursor: not-allowed;
802
+ }
803
+ .vm-more-icon {
804
+ width: 20px;
805
+ text-align: center;
806
+ font-size: 16px;
807
+ flex-shrink: 0;
808
+ }
809
+ .vm-more-divider {
810
+ height: 1px;
811
+ background: rgba(255,255,255,0.08);
812
+ margin: 4px 0;
813
+ }
814
+
815
+ /* ── Improved split-button for mic/speaker arrows ────────────────── */
816
+ .vm-btn-with-arrow {
817
+ display: flex;
818
+ align-items: stretch;
819
+ gap: 0;
820
+ }
821
+ .vm-btn-arrow {
822
+ width: 24px;
823
+ height: 48px;
824
+ border-radius: 0 24px 24px 0;
825
+ border: none;
826
+ background: #3c4043;
827
+ color: rgba(255,255,255,0.7);
828
+ cursor: pointer;
829
+ font-size: 10px;
830
+ display: flex;
831
+ align-items: center;
832
+ justify-content: center;
833
+ border-left: 1px solid rgba(255,255,255,0.08);
834
+ transition: background 0.15s;
835
+ }
836
+ .vm-btn-arrow:hover { background: #5f6368; }
837
+ .vm-btn.has-arrow {
838
+ border-radius: 24px 0 0 24px;
839
+ }
840
+
841
+ /* ── Bar device chips (below buttons like Meet) ─────────────────── */
842
+ .vm-bar-device-chips {
843
+ position: absolute;
844
+ bottom: 4px;
845
+ left: 0; right: 0;
846
+ display: flex;
847
+ align-items: center;
848
+ justify-content: center;
849
+ gap: 12px;
850
+ padding: 0 20px;
851
+ z-index: 2;
852
+ pointer-events: none;
853
+ }
854
+ .vm-bar-device-chip {
855
+ pointer-events: auto;
856
+ display: flex;
857
+ align-items: center;
858
+ gap: 6px;
859
+ background: rgba(60,64,67,0.85);
860
+ border: 1px solid rgba(255,255,255,0.12);
861
+ border-radius: 24px;
862
+ padding: 4px 12px 4px 8px;
863
+ color: rgba(255,255,255,0.8);
864
+ font-size: 11px;
865
+ cursor: pointer;
866
+ transition: background 0.15s, border-color 0.15s;
867
+ max-width: 220px;
868
+ overflow: hidden;
869
+ text-overflow: ellipsis;
870
+ white-space: nowrap;
871
+ }
872
+ .vm-bar-device-chip:hover {
873
+ background: rgba(95,99,104,0.92);
874
+ border-color: rgba(255,255,255,0.22);
875
+ }
876
+ .vm-bar-device-chip-icon {
877
+ font-size: 14px;
878
+ flex-shrink: 0;
879
+ }
564
880
  `;
565
881
  document.head.appendChild(style);
566
882
  }
@@ -878,6 +1194,8 @@ function mergeFragmentedMeetingFeedMessages(items) {
878
1194
  * executor?: string,
879
1195
  * mode?: string,
880
1196
  * model?: string,
1197
+ * voiceAgentId?: string,
1198
+ * onVoiceAgentChange?: (voiceAgentId: string) => void,
881
1199
  * callType?: "voice" | "video",
882
1200
  * initialVisionSource?: "camera" | "screen" | null
883
1201
  * }} props
@@ -892,6 +1210,8 @@ export function VoiceOverlay({
892
1210
  executor,
893
1211
  mode,
894
1212
  model,
1213
+ voiceAgentId,
1214
+ onVoiceAgentChange,
895
1215
  callType = "voice",
896
1216
  initialVisionSource = null,
897
1217
  }) {
@@ -915,9 +1235,81 @@ export function VoiceOverlay({
915
1235
  const sdkFallbackCleanupRef = useRef(null);
916
1236
  const legacyFallbackCleanupRef = useRef(null);
917
1237
  const autoFallbackTriedRef = useRef(false);
1238
+ const [showMicPicker, setShowMicPicker] = useState(false);
1239
+ const [showSpeakerPicker, setShowSpeakerPicker] = useState(false);
1240
+ const [showMoreMenu, setShowMoreMenu] = useState(false);
1241
+ const [showPeoplePanel, setShowPeoplePanel] = useState(false);
1242
+ const [showSettings, setShowSettings] = useState(false);
1243
+ const [voiceAgents, setVoiceAgents] = useState([]);
1244
+ const [loadingVoiceAgents, setLoadingVoiceAgents] = useState(false);
1245
+ const [selectedVoiceAgentId, setSelectedVoiceAgentId] = useState(
1246
+ String(voiceAgentId || "").trim() || "",
1247
+ );
1248
+ const [startRequested, setStartRequested] = useState(false);
1249
+ const [switchingVoiceAgent, setSwitchingVoiceAgent] = useState(false);
1250
+ const preserveSessionOnHideRef = useRef(false);
918
1251
 
919
1252
  useEffect(() => { injectOverlayStyles(); }, []);
920
1253
 
1254
+ useEffect(() => {
1255
+ const incoming = String(voiceAgentId || "").trim();
1256
+ if (!incoming) return;
1257
+ setSelectedVoiceAgentId(incoming);
1258
+ }, [voiceAgentId]);
1259
+
1260
+ useEffect(() => {
1261
+ if (!visible) return;
1262
+ let cancelled = false;
1263
+ setLoadingVoiceAgents(true);
1264
+ (async () => {
1265
+ try {
1266
+ const params = new URLSearchParams();
1267
+ if (sessionId) params.set("sessionId", String(sessionId));
1268
+ if (voiceAgentId) params.set("voiceAgentId", String(voiceAgentId));
1269
+ const res = await apiFetch(`/api/voice/agents?${params.toString()}`, { _silent: true });
1270
+ if (cancelled) return;
1271
+ const agents = Array.isArray(res?.agents) ? res.agents : [];
1272
+ setVoiceAgents(agents);
1273
+ const fallbackId =
1274
+ String(voiceAgentId || "").trim()
1275
+ || String(res?.defaultAgentId || "").trim()
1276
+ || String(agents[0]?.id || "").trim()
1277
+ || "";
1278
+ setSelectedVoiceAgentId(fallbackId);
1279
+ if (typeof onVoiceAgentChange === "function" && fallbackId) {
1280
+ onVoiceAgentChange(fallbackId);
1281
+ }
1282
+ } catch {
1283
+ if (!cancelled) {
1284
+ setVoiceAgents([]);
1285
+ if (!selectedVoiceAgentId) setSelectedVoiceAgentId("voice-agent");
1286
+ }
1287
+ } finally {
1288
+ if (!cancelled) setLoadingVoiceAgents(false);
1289
+ }
1290
+ })();
1291
+ return () => {
1292
+ cancelled = true;
1293
+ };
1294
+ }, [visible, sessionId, voiceAgentId, onVoiceAgentChange]);
1295
+
1296
+ useEffect(() => {
1297
+ if (visible) {
1298
+ preserveSessionOnHideRef.current = false;
1299
+ }
1300
+ if (visible) return;
1301
+ setStartRequested(false);
1302
+ setSwitchingVoiceAgent(false);
1303
+ }, [visible]);
1304
+
1305
+ // Close popups on outside click
1306
+ useEffect(() => {
1307
+ if (!visible) return;
1308
+ const handler = () => { setShowMicPicker(false); setShowSpeakerPicker(false); setShowMoreMenu(false); setShowPeoplePanel(false); };
1309
+ document.addEventListener("click", handler);
1310
+ return () => document.removeEventListener("click", handler);
1311
+ }, [visible]);
1312
+
921
1313
  // Determine effective tier: SDK takes over tier 1 when active
922
1314
  const effectiveSdk = usingSdk && sdkVoiceSdkActive.value;
923
1315
 
@@ -961,7 +1353,7 @@ export function VoiceOverlay({
961
1353
 
962
1354
  // Start session on mount — try Agents SDK first, fallback to legacy
963
1355
  useEffect(() => {
964
- if (!visible || started) return;
1356
+ if (!visible || started || !startRequested) return;
965
1357
  setStarted(true);
966
1358
  autoFallbackTriedRef.current = false;
967
1359
  let legacyFallbackStarted = false;
@@ -970,12 +1362,12 @@ export function VoiceOverlay({
970
1362
  if (legacyFallbackStarted) return;
971
1363
  legacyFallbackStarted = true;
972
1364
  setUsingSdk(false);
973
- startVoiceSession({ sessionId, executor, mode, model });
1365
+ startVoiceSession({ sessionId, executor, mode, model, voiceAgentId: selectedVoiceAgentId || undefined });
974
1366
  };
975
1367
 
976
1368
  if (tier === 1) {
977
1369
  // Try SDK-first for tier 1
978
- startSdkVoiceSession({ sessionId, executor, mode, model })
1370
+ startSdkVoiceSession({ sessionId, executor, mode, model, voiceAgentId: selectedVoiceAgentId || undefined })
979
1371
  .then((result) => {
980
1372
  if (result.sdk) {
981
1373
  setUsingSdk(true);
@@ -995,9 +1387,9 @@ export function VoiceOverlay({
995
1387
  });
996
1388
  sdkFallbackCleanupRef.current = cleanup;
997
1389
  } else if (sessionId) {
998
- startFallbackSession(sessionId, { executor, mode, model });
1390
+ startFallbackSession(sessionId, { executor, mode, model, voiceAgentId: selectedVoiceAgentId || undefined });
999
1391
  }
1000
- }, [visible, started, tier, sessionId, executor, mode, model]);
1392
+ }, [visible, started, startRequested, tier, sessionId, executor, mode, model, selectedVoiceAgentId]);
1001
1393
 
1002
1394
  useEffect(() => {
1003
1395
  if (!visible || !started || tier !== 1 || !sessionId) return;
@@ -1006,11 +1398,16 @@ export function VoiceOverlay({
1006
1398
  if (autoFallbackTriedRef.current) return;
1007
1399
  autoFallbackTriedRef.current = true;
1008
1400
  try { stopVoiceSession(); } catch { /* best effort */ }
1009
- startFallbackSession(sessionId, { executor, mode, model });
1401
+ startFallbackSession(sessionId, {
1402
+ executor,
1403
+ mode,
1404
+ model,
1405
+ voiceAgentId: selectedVoiceAgentId || undefined,
1406
+ });
1010
1407
  });
1011
1408
  legacyFallbackCleanupRef.current = cleanup;
1012
1409
  return cleanup;
1013
- }, [visible, started, tier, sessionId, executor, mode, model, usingSdk]);
1410
+ }, [visible, started, tier, sessionId, executor, mode, model, usingSdk, selectedVoiceAgentId]);
1014
1411
 
1015
1412
  useEffect(() => {
1016
1413
  if (visible) return;
@@ -1022,8 +1419,17 @@ export function VoiceOverlay({
1022
1419
  autoVisionAppliedRef.current = false;
1023
1420
  }, [visible]);
1024
1421
 
1422
+ const stopAllVoiceTransports = useCallback(() => {
1423
+ try { stopSdkVoiceSession(); } catch { /* best effort */ }
1424
+ try { stopVoiceSession(); } catch { /* best effort */ }
1425
+ try { stopFallbackSession(); } catch { /* best effort */ }
1426
+ }, []);
1427
+
1025
1428
  useEffect(() => {
1026
1429
  if (visible || !started) return;
1430
+ if (preserveSessionOnHideRef.current) {
1431
+ return;
1432
+ }
1027
1433
  stopVisionShare().catch(() => {});
1028
1434
  if (typeof sdkFallbackCleanupRef.current === "function") {
1029
1435
  sdkFallbackCleanupRef.current();
@@ -1033,17 +1439,11 @@ export function VoiceOverlay({
1033
1439
  legacyFallbackCleanupRef.current();
1034
1440
  legacyFallbackCleanupRef.current = null;
1035
1441
  }
1036
- if (usingSdk) {
1037
- stopSdkVoiceSession();
1038
- } else if (tier === 1) {
1039
- stopVoiceSession();
1040
- } else {
1041
- stopFallbackSession();
1042
- }
1442
+ stopAllVoiceTransports();
1043
1443
  setStarted(false);
1044
1444
  setUsingSdk(false);
1045
1445
  autoFallbackTriedRef.current = false;
1046
- }, [visible, started, usingSdk, tier]);
1446
+ }, [visible, started, stopAllVoiceTransports]);
1047
1447
 
1048
1448
  const loadMeetingMessages = useCallback(async () => {
1049
1449
  const activeSessionId = String(sessionId || "").trim();
@@ -1195,21 +1595,21 @@ export function VoiceOverlay({
1195
1595
  legacyFallbackCleanupRef.current();
1196
1596
  legacyFallbackCleanupRef.current = null;
1197
1597
  }
1198
- if (usingSdk) {
1199
- stopSdkVoiceSession();
1200
- } else if (tier === 1) {
1201
- stopVoiceSession();
1202
- } else {
1203
- stopFallbackSession();
1204
- }
1598
+ stopAllVoiceTransports();
1599
+ preserveSessionOnHideRef.current = false;
1205
1600
  autoFallbackTriedRef.current = false;
1206
1601
  setUsingSdk(false);
1207
1602
  setStarted(false);
1208
1603
  onClose();
1209
- }, [tier, onClose, usingSdk]);
1604
+ }, [onClose, stopAllVoiceTransports]);
1210
1605
 
1211
1606
  const handleDismiss = useCallback((detail = {}) => {
1212
1607
  haptic("light");
1608
+ const reason = String(detail?.reason || "").trim().toLowerCase();
1609
+ if (reason === "externalize") {
1610
+ // Externalize hides this overlay while the live call continues.
1611
+ preserveSessionOnHideRef.current = true;
1612
+ }
1213
1613
  const fn = typeof onDismiss === "function" ? onDismiss : onClose;
1214
1614
  fn(detail);
1215
1615
  }, [onDismiss, onClose]);
@@ -1275,8 +1675,12 @@ export function VoiceOverlay({
1275
1675
 
1276
1676
  const handleToggleMic = useCallback(() => {
1277
1677
  haptic("light");
1678
+ if (effectiveSdk) {
1679
+ toggleSdkMicMute();
1680
+ return;
1681
+ }
1278
1682
  toggleMicMute();
1279
- }, []);
1683
+ }, [effectiveSdk]);
1280
1684
 
1281
1685
  const handleBackToApp = useCallback(() => {
1282
1686
  haptic("light");
@@ -1301,17 +1705,11 @@ export function VoiceOverlay({
1301
1705
  useEffect(() => () => {
1302
1706
  try {
1303
1707
  stopVisionShare().catch(() => {});
1304
- if (usingSdk) {
1305
- stopSdkVoiceSession();
1306
- } else if (tier === 1) {
1307
- stopVoiceSession();
1308
- } else {
1309
- stopFallbackSession();
1310
- }
1708
+ stopAllVoiceTransports();
1311
1709
  } catch {
1312
1710
  // best effort cleanup
1313
1711
  }
1314
- }, [tier, usingSdk]);
1712
+ }, [stopAllVoiceTransports]);
1315
1713
 
1316
1714
  const handleExpand = useCallback(() => {
1317
1715
  haptic("light");
@@ -1462,12 +1860,87 @@ export function VoiceOverlay({
1462
1860
 
1463
1861
  if (!visible) return null;
1464
1862
 
1863
+ const activeVoiceAgent = voiceAgents.find(
1864
+ (agent) => String(agent?.id || "").trim() === String(selectedVoiceAgentId || "").trim(),
1865
+ ) || null;
1866
+
1867
+ const handleVoiceAgentSelection = async (nextAgentIdRaw) => {
1868
+ const nextAgentId = String(nextAgentIdRaw || "").trim();
1869
+ if (!nextAgentId) return;
1870
+ if (nextAgentId === String(selectedVoiceAgentId || "").trim()) return;
1871
+ setSelectedVoiceAgentId(nextAgentId);
1872
+ if (typeof onVoiceAgentChange === "function") onVoiceAgentChange(nextAgentId);
1873
+ if (!started) return;
1874
+
1875
+ setSwitchingVoiceAgent(true);
1876
+ try {
1877
+ stopVisionShare().catch(() => {});
1878
+ stopAllVoiceTransports();
1879
+ autoFallbackTriedRef.current = false;
1880
+ setUsingSdk(false);
1881
+ setStarted(false);
1882
+ setStartRequested(true);
1883
+ } finally {
1884
+ setSwitchingVoiceAgent(false);
1885
+ }
1886
+ };
1887
+
1888
+ if (!startRequested) {
1889
+ return html`
1890
+ <div class=${`voice-overlay${isCompactFollowMode ? " compact" : ""}`}>
1891
+ <div class="voice-overlay-main" style="display:flex;align-items:center;justify-content:center;padding:24px;">
1892
+ <div class="vm-settings-panel" style="max-width:560px;width:100%;">
1893
+ <div class="vm-settings-header">
1894
+ <span class="vm-settings-title">${normalizedCallType === "video" ? "Start Video Call" : "Start Voice Call"}</span>
1895
+ </div>
1896
+ <div class="vm-settings-body">
1897
+ <div class="vm-settings-section">
1898
+ <div class="vm-settings-section-title">Voice Agent</div>
1899
+ <div class="vm-settings-row">
1900
+ <div class="vm-settings-row-info">
1901
+ <div class="vm-settings-row-label">Choose starting agent</div>
1902
+ <div class="vm-settings-row-desc">
1903
+ ${loadingVoiceAgents
1904
+ ? "Loading available audio agents…"
1905
+ : (activeVoiceAgent?.description || "Pick the voice agent for this call.")}
1906
+ </div>
1907
+ </div>
1908
+ <select
1909
+ class="vm-settings-select"
1910
+ disabled=${loadingVoiceAgents || switchingVoiceAgent}
1911
+ value=${selectedVoiceAgentId || ""}
1912
+ onChange=${(e) => handleVoiceAgentSelection(e.target.value)}
1913
+ >
1914
+ ${(voiceAgents.length ? voiceAgents : [{ id: "voice-agent", name: "Voice Agent" }]).map((agent) => html`
1915
+ <option key=${agent.id} value=${agent.id}>${agent.name || agent.id}</option>
1916
+ `)}
1917
+ </select>
1918
+ </div>
1919
+ </div>
1920
+ <div class="library-actions" style="margin-top:10px;">
1921
+ <button class="btn-ghost" onClick=${onClose}>Cancel</button>
1922
+ <button
1923
+ class="btn-primary"
1924
+ disabled=${loadingVoiceAgents || !String(selectedVoiceAgentId || "").trim()}
1925
+ onClick=${() => setStartRequested(true)}
1926
+ >
1927
+ Start Call
1928
+ </button>
1929
+ </div>
1930
+ </div>
1931
+ </div>
1932
+ </div>
1933
+ </div>
1934
+ `;
1935
+ }
1936
+
1465
1937
  const statusLabel = state === "connected" ? "ready" : state;
1466
1938
  const boundLabel = [
1467
1939
  sessionId ? `session ${sessionId}` : null,
1468
1940
  executor ? `agent ${executor}` : null,
1469
1941
  mode ? `mode ${mode}` : null,
1470
1942
  model ? `model ${model}` : null,
1943
+ activeVoiceAgent?.name ? `voice ${activeVoiceAgent.name}` : null,
1471
1944
  ]
1472
1945
  .filter(Boolean)
1473
1946
  .join(" · ");
@@ -1816,27 +2289,119 @@ export function VoiceOverlay({
1816
2289
 
1817
2290
  <!-- Google Meet-style bottom controls bar -->
1818
2291
  <div class="vm-bar">
1819
- <!-- Left: duration -->
2292
+ <!-- Left: three-dot menu + duration -->
1820
2293
  <div class="vm-bar-group left">
2294
+ <div class="vm-btn-wrap" style="position:relative">
2295
+ <button
2296
+ class="vm-btn"
2297
+ onClick=${(e) => { e.stopPropagation(); setShowMoreMenu(p => !p); setShowMicPicker(false); setShowSpeakerPicker(false); setShowPeoplePanel(false); }}
2298
+ title="More options"
2299
+ >⋯</button>
2300
+ <span class="vm-btn-label">More</span>
2301
+ ${showMoreMenu && html`
2302
+ <div class="vm-more-menu" onClick=${(e) => e.stopPropagation()}>
2303
+ <button class="vm-more-item" onClick=${() => { setShowSettings(true); setShowMoreMenu(false); }}>
2304
+ <span class="vm-more-icon">⚙️</span> Settings
2305
+ </button>
2306
+ <button class="vm-more-item" onClick=${() => { setShowMoreMenu(false); handleMinimize(); }}>
2307
+ <span class="vm-more-icon">🖼</span> Open picture-in-picture
2308
+ </button>
2309
+ <button class="vm-more-item" onClick=${() => {
2310
+ if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); }
2311
+ else { document.documentElement.requestFullscreen?.().catch(() => {}); }
2312
+ setShowMoreMenu(false);
2313
+ }}>
2314
+ <span class="vm-more-icon">${document.fullscreenElement ? "↙" : "⛶"}</span> ${document.fullscreenElement ? "Exit full screen" : "Full screen"}
2315
+ </button>
2316
+ <div class="vm-more-divider"></div>
2317
+ <button class="vm-more-item disabled" disabled>
2318
+ <span class="vm-more-icon">⏺</span> Recording unavailable
2319
+ </button>
2320
+ <div class="vm-more-divider"></div>
2321
+ <button class="vm-more-item" onClick=${() => {
2322
+ showToast("Troubleshooting: Check mic permissions and network connection", "info");
2323
+ setShowMoreMenu(false);
2324
+ }}>
2325
+ <span class="vm-more-icon">🔧</span> Troubleshooting & help
2326
+ </button>
2327
+ </div>
2328
+ `}
2329
+ </div>
1821
2330
  ${duration > 0 && html`
1822
- <span style="font-size:13px;color:rgba(255,255,255,0.7);font-variant-numeric:tabular-nums">
2331
+ <span style="font-size:13px;color:rgba(255,255,255,0.7);font-variant-numeric:tabular-nums;margin-left:8px">
1823
2332
  ${formatDuration(duration)}
1824
2333
  </span>
1825
2334
  `}
1826
2335
  </div>
1827
2336
 
1828
- <!-- Center: mic, camera, screen, end-call pill -->
2337
+ <!-- Center: mic (with arrow), camera, screen, emoji, hand-raise, end-call -->
1829
2338
  <div class="vm-bar-group center">
1830
- <!-- Mic toggle -->
2339
+ <!-- Mic toggle with device picker arrow -->
1831
2340
  <div class="vm-btn-wrap">
1832
- <button
1833
- class=${`vm-btn${micMuted ? " muted" : ""}`}
1834
- onClick=${handleToggleMic}
1835
- title=${micMuted ? "Unmute mic" : "Mute mic"}
1836
- >
1837
- ${micMuted ? "🔇" : "🎙"}
1838
- </button>
2341
+ <div class="vm-btn-with-arrow">
2342
+ <button
2343
+ class=${`vm-btn has-arrow${micMuted ? " muted" : ""}`}
2344
+ onClick=${handleToggleMic}
2345
+ title=${micMuted ? "Unmute mic" : "Mute mic"}
2346
+ >
2347
+ ${micMuted ? "🔇" : "🎙"}
2348
+ </button>
2349
+ <button
2350
+ class="vm-btn-arrow"
2351
+ onClick=${(e) => { e.stopPropagation(); setShowMicPicker(p => !p); setShowSpeakerPicker(false); setShowMoreMenu(false); }}
2352
+ title="Audio settings"
2353
+ >▲</button>
2354
+ </div>
1839
2355
  <span class="vm-btn-label">${micMuted ? "Unmute" : "Mute"}</span>
2356
+ ${showMicPicker && html`
2357
+ <div class="vm-device-picker" onClick=${(e) => e.stopPropagation()}>
2358
+ <div class="vm-device-picker-section">
2359
+ <div class="vm-device-picker-label">Microphone</div>
2360
+ ${audioInputDevices.value.map(d => html`
2361
+ <button
2362
+ key=${d.deviceId}
2363
+ class=${`vm-device-item${(selectedAudioInput.value || "") === d.deviceId || (!selectedAudioInput.value && d.deviceId === "default") ? " active" : ""}`}
2364
+ onClick=${() => { switchAudioInput(d.deviceId); }}
2365
+ >
2366
+ <div class=${`vm-device-check${(selectedAudioInput.value || "") === d.deviceId || (!selectedAudioInput.value && d.deviceId === "default") ? " selected" : ""}`}>
2367
+ ${(selectedAudioInput.value || "") === d.deviceId || (!selectedAudioInput.value && d.deviceId === "default") ? "✓" : ""}
2368
+ </div>
2369
+ <div class="vm-device-name">
2370
+ ${d.label || "Microphone " + d.deviceId.slice(0, 8)}
2371
+ ${d.deviceId === "default" ? html`<div class="vm-device-default">System default</div>` : ""}
2372
+ </div>
2373
+ </button>
2374
+ `)}
2375
+ ${audioInputDevices.value.length === 0 && html`
2376
+ <div style="padding:8px 16px;font-size:12px;color:rgba(255,255,255,0.45)">No microphones detected</div>
2377
+ `}
2378
+ </div>
2379
+ <div class="vm-mic-level-bar">
2380
+ <div class="vm-mic-level-fill" style=${`width:${Math.round(micInputLevel.value * 100)}%`}></div>
2381
+ </div>
2382
+ <div class="vm-device-picker-section">
2383
+ <div class="vm-device-picker-label">Speakers</div>
2384
+ ${audioOutputDevices.value.map(d => html`
2385
+ <button
2386
+ key=${d.deviceId}
2387
+ class=${`vm-device-item${(selectedAudioOutput.value || "") === d.deviceId || (!selectedAudioOutput.value && d.deviceId === "default") ? " active" : ""}`}
2388
+ onClick=${() => { switchAudioOutput(d.deviceId); }}
2389
+ >
2390
+ <div class=${`vm-device-check${(selectedAudioOutput.value || "") === d.deviceId || (!selectedAudioOutput.value && d.deviceId === "default") ? " selected" : ""}`}>
2391
+ ${(selectedAudioOutput.value || "") === d.deviceId || (!selectedAudioOutput.value && d.deviceId === "default") ? "✓" : ""}
2392
+ </div>
2393
+ <div class="vm-device-name">
2394
+ ${d.label || "Speaker " + d.deviceId.slice(0, 8)}
2395
+ ${d.deviceId === "default" ? html`<div class="vm-device-default">System default</div>` : ""}
2396
+ </div>
2397
+ </button>
2398
+ `)}
2399
+ ${audioOutputDevices.value.length === 0 && html`
2400
+ <div style="padding:8px 16px;font-size:12px;color:rgba(255,255,255,0.45)">No speakers detected</div>
2401
+ `}
2402
+ </div>
2403
+ </div>
2404
+ `}
1840
2405
  </div>
1841
2406
 
1842
2407
  <!-- Camera toggle -->
@@ -1874,7 +2439,7 @@ export function VoiceOverlay({
1874
2439
  </div>
1875
2440
  </div>
1876
2441
 
1877
- <!-- Right: chat toggle, people -->
2442
+ <!-- Right: chat, people, settings -->
1878
2443
  <div class="vm-bar-group right">
1879
2444
  ${!isCompactFollowMode && html`
1880
2445
  <div class="vm-btn-wrap">
@@ -1889,14 +2454,231 @@ export function VoiceOverlay({
1889
2454
  <span class="vm-btn-label">Chat</span>
1890
2455
  </div>
1891
2456
  `}
1892
- <div class="vm-btn-wrap">
1893
- <button class="vm-btn" title="Participants (coming soon)" disabled>
2457
+ <div class="vm-btn-wrap" style="position:relative">
2458
+ <button
2459
+ class=${`vm-btn${showPeoplePanel ? " active" : ""}`}
2460
+ onClick=${(e) => { e.stopPropagation(); setShowPeoplePanel(p => !p); setShowMoreMenu(false); setShowMicPicker(false); setShowSpeakerPicker(false); }}
2461
+ title="Participants"
2462
+ >
1894
2463
  👥
1895
2464
  </button>
1896
2465
  <span class="vm-btn-label">People</span>
2466
+ ${showPeoplePanel && html`
2467
+ <div class="vm-more-menu" style="bottom:calc(100% + 10px);left:50%;transform:translateX(-50%);min-width:220px" onClick=${(e) => e.stopPropagation()}>
2468
+ <div style="padding:10px 14px 6px;font-size:12px;color:rgba(255,255,255,0.5);font-weight:600;letter-spacing:0.04em">PARTICIPANTS</div>
2469
+ <div class="vm-more-item" style="cursor:default;opacity:0.9">
2470
+ <span class="vm-more-icon">🧑</span> You
2471
+ </div>
2472
+ <div class="vm-more-item" style="cursor:default;opacity:0.9">
2473
+ <span class="vm-more-icon">🤖</span> ${activeVoiceAgent?.name || "AI Agent"}
2474
+ </div>
2475
+ <div class="vm-more-divider"></div>
2476
+ <div style="padding:8px 12px 6px;font-size:11px;color:rgba(255,255,255,0.5);font-weight:600;letter-spacing:0.04em">SWITCH AGENT</div>
2477
+ ${(voiceAgents.length ? voiceAgents : [{ id: "voice-agent", name: "Voice Agent" }]).map((agent) => html`
2478
+ <button
2479
+ key=${agent.id}
2480
+ class="vm-more-item"
2481
+ disabled=${switchingVoiceAgent}
2482
+ onClick=${() => handleVoiceAgentSelection(agent.id)}
2483
+ style=${String(agent.id) === String(selectedVoiceAgentId || "").trim() ? "opacity:0.95;border:1px solid rgba(138,180,248,0.35);" : ""}
2484
+ >
2485
+ <span class="vm-more-icon">${String(agent.id) === String(selectedVoiceAgentId || "").trim() ? "✅" : "🤖"}</span>
2486
+ ${agent.name || agent.id}
2487
+ </button>
2488
+ `)}
2489
+ <div class="vm-more-divider"></div>
2490
+ <button class="vm-more-item" onClick=${() => {
2491
+ const url = window.location.href;
2492
+ navigator.clipboard?.writeText(url).then(() => {
2493
+ showToast("Invite link copied!", "success");
2494
+ }).catch(() => {
2495
+ showToast(url, "info");
2496
+ });
2497
+ setShowPeoplePanel(false);
2498
+ }}>
2499
+ <span class="vm-more-icon">🔗</span> Copy invite link
2500
+ </button>
2501
+ </div>
2502
+ `}
2503
+ </div>
2504
+ <div class="vm-btn-wrap">
2505
+ <button class="vm-btn" onClick=${() => setShowSettings(true)} title="Settings">
2506
+ ⚙️
2507
+ </button>
2508
+ <span class="vm-btn-label">Settings</span>
1897
2509
  </div>
1898
2510
  </div>
1899
2511
  </div>
2512
+
2513
+ <!-- Device chips bar (below main controls, like Meet) -->
2514
+ <div class="vm-bar-device-chips">
2515
+ <button
2516
+ class="vm-bar-device-chip"
2517
+ onClick=${() => { setShowMicPicker(p => !p); setShowSpeakerPicker(false); }}
2518
+ >
2519
+ <span class="vm-bar-device-chip-icon">🎙</span>
2520
+ ${(() => {
2521
+ const sel = selectedAudioInput.value;
2522
+ const dev = audioInputDevices.value.find(d => d.deviceId === sel);
2523
+ return dev?.label || "System default";
2524
+ })()}
2525
+ <span style="font-size:10px;margin-left:2px">▲</span>
2526
+ </button>
2527
+ <button
2528
+ class="vm-bar-device-chip"
2529
+ onClick=${() => { setShowSpeakerPicker(p => !p); setShowMicPicker(false); }}
2530
+ >
2531
+ <span class="vm-bar-device-chip-icon">🔊</span>
2532
+ ${(() => {
2533
+ const sel = selectedAudioOutput.value;
2534
+ const dev = audioOutputDevices.value.find(d => d.deviceId === sel);
2535
+ return dev?.label || "System default";
2536
+ })()}
2537
+ <span style="font-size:10px;margin-left:2px">▲</span>
2538
+ </button>
2539
+ </div>
2540
+
2541
+ <!-- Settings panel overlay -->
2542
+ ${showSettings && html`
2543
+ <div class="vm-settings-overlay" onClick=${() => setShowSettings(false)}>
2544
+ <div class="vm-settings-panel" onClick=${(e) => e.stopPropagation()}>
2545
+ <div class="vm-settings-header">
2546
+ <span class="vm-settings-title">Settings</span>
2547
+ <button class="vm-settings-close" onClick=${() => setShowSettings(false)}>✕</button>
2548
+ </div>
2549
+ <div class="vm-settings-body">
2550
+ <!-- Audio section -->
2551
+ <div class="vm-settings-section">
2552
+ <div class="vm-settings-section-title">🎙 Audio</div>
2553
+
2554
+ <div class="vm-settings-row">
2555
+ <div class="vm-settings-row-info">
2556
+ <div class="vm-settings-row-label">Microphone</div>
2557
+ </div>
2558
+ <select
2559
+ class="vm-settings-select"
2560
+ value=${selectedAudioInput.value || ""}
2561
+ onChange=${(e) => switchAudioInput(e.target.value)}
2562
+ >
2563
+ ${audioInputDevices.value.map(d => html`
2564
+ <option key=${d.deviceId} value=${d.deviceId}>${d.label || "Mic " + d.deviceId.slice(0, 8)}</option>
2565
+ `)}
2566
+ </select>
2567
+ </div>
2568
+
2569
+ <!-- Mic level -->
2570
+ <div class="vm-mic-level-bar" style="margin:0 0 8px">
2571
+ <div class="vm-mic-level-fill" style=${`width:${Math.round(micInputLevel.value * 100)}%`}></div>
2572
+ </div>
2573
+
2574
+ <div class="vm-settings-row">
2575
+ <div class="vm-settings-row-info">
2576
+ <div class="vm-settings-row-label">Speakers</div>
2577
+ </div>
2578
+ <select
2579
+ class="vm-settings-select"
2580
+ value=${selectedAudioOutput.value || ""}
2581
+ onChange=${(e) => switchAudioOutput(e.target.value)}
2582
+ >
2583
+ ${audioOutputDevices.value.map(d => html`
2584
+ <option key=${d.deviceId} value=${d.deviceId}>${d.label || "Speaker " + d.deviceId.slice(0, 8)}</option>
2585
+ `)}
2586
+ </select>
2587
+ </div>
2588
+ </div>
2589
+
2590
+ <!-- Audio processing section -->
2591
+ <div class="vm-settings-section">
2592
+ <div class="vm-settings-section-title">🔧 Audio Processing</div>
2593
+
2594
+ <div class="vm-settings-row">
2595
+ <div class="vm-settings-row-info">
2596
+ <div class="vm-settings-row-label">Echo cancellation</div>
2597
+ <div class="vm-settings-row-desc">Reduces echo from speakers being picked up by mic</div>
2598
+ </div>
2599
+ <button
2600
+ class=${`vm-settings-toggle${audioSettings.value.echoCancellation ? " on" : ""}`}
2601
+ onClick=${() => updateAudioSettings({ echoCancellation: !audioSettings.value.echoCancellation })}
2602
+ />
2603
+ </div>
2604
+
2605
+ <div class="vm-settings-row">
2606
+ <div class="vm-settings-row-info">
2607
+ <div class="vm-settings-row-label">Noise suppression</div>
2608
+ <div class="vm-settings-row-desc">Filters background noise from microphone input</div>
2609
+ </div>
2610
+ <button
2611
+ class=${`vm-settings-toggle${audioSettings.value.noiseSuppression ? " on" : ""}`}
2612
+ onClick=${() => updateAudioSettings({ noiseSuppression: !audioSettings.value.noiseSuppression })}
2613
+ />
2614
+ </div>
2615
+
2616
+ <div class="vm-settings-row">
2617
+ <div class="vm-settings-row-info">
2618
+ <div class="vm-settings-row-label">Auto gain control</div>
2619
+ <div class="vm-settings-row-desc">Automatically adjusts microphone volume level</div>
2620
+ </div>
2621
+ <button
2622
+ class=${`vm-settings-toggle${audioSettings.value.autoGainControl ? " on" : ""}`}
2623
+ onClick=${() => updateAudioSettings({ autoGainControl: !audioSettings.value.autoGainControl })}
2624
+ />
2625
+ </div>
2626
+ </div>
2627
+
2628
+ <!-- Call info section -->
2629
+ <div class="vm-settings-section">
2630
+ <div class="vm-settings-section-title">📞 Call Info</div>
2631
+ <div class="vm-settings-row">
2632
+ <div class="vm-settings-row-info">
2633
+ <div class="vm-settings-row-label">Session</div>
2634
+ <div class="vm-settings-row-desc">${sessionId || "No active session"}</div>
2635
+ </div>
2636
+ </div>
2637
+ <div class="vm-settings-row">
2638
+ <div class="vm-settings-row-info">
2639
+ <div class="vm-settings-row-label">Agent</div>
2640
+ <div class="vm-settings-row-desc">${activeVoiceAgent?.name || "Default"}</div>
2641
+ </div>
2642
+ </div>
2643
+ <div class="vm-settings-row">
2644
+ <div class="vm-settings-row-info">
2645
+ <div class="vm-settings-row-label">Switch voice agent</div>
2646
+ <div class="vm-settings-row-desc">Change persona/toolset without leaving this call</div>
2647
+ </div>
2648
+ <select
2649
+ class="vm-settings-select"
2650
+ disabled=${switchingVoiceAgent}
2651
+ value=${selectedVoiceAgentId || ""}
2652
+ onChange=${(e) => handleVoiceAgentSelection(e.target.value)}
2653
+ >
2654
+ ${(voiceAgents.length ? voiceAgents : [{ id: "voice-agent", name: "Voice Agent" }]).map((agent) => html`
2655
+ <option key=${agent.id} value=${agent.id}>${agent.name || agent.id}</option>
2656
+ `)}
2657
+ </select>
2658
+ </div>
2659
+ <div class="vm-settings-row">
2660
+ <div class="vm-settings-row-info">
2661
+ <div class="vm-settings-row-label">Model</div>
2662
+ <div class="vm-settings-row-desc">${model || "Default"}</div>
2663
+ </div>
2664
+ </div>
2665
+ <div class="vm-settings-row">
2666
+ <div class="vm-settings-row-info">
2667
+ <div class="vm-settings-row-label">Transport</div>
2668
+ <div class="vm-settings-row-desc">${usingSdk ? "Agents SDK" : "WebRTC Legacy"}</div>
2669
+ </div>
2670
+ </div>
2671
+ <div class="vm-settings-row">
2672
+ <div class="vm-settings-row-info">
2673
+ <div class="vm-settings-row-label">Duration</div>
2674
+ <div class="vm-settings-row-desc">${duration > 0 ? formatDuration(duration) : "Not started"}</div>
2675
+ </div>
2676
+ </div>
2677
+ </div>
2678
+ </div>
2679
+ </div>
2680
+ </div>
2681
+ `}
1900
2682
  </div>
1901
2683
  `}
1902
2684