bosun 0.36.2 → 0.36.4

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 (57) hide show
  1. package/agent-prompts.mjs +95 -0
  2. package/analyze-agent-work-helpers.mjs +308 -0
  3. package/analyze-agent-work.mjs +926 -0
  4. package/autofix.mjs +2 -0
  5. package/bosun.schema.json +101 -3
  6. package/codex-shell.mjs +85 -10
  7. package/desktop/main.mjs +871 -48
  8. package/desktop/preload.mjs +54 -1
  9. package/desktop-shortcut.mjs +90 -11
  10. package/git-editor-fix.mjs +273 -0
  11. package/mcp-registry.mjs +579 -0
  12. package/meeting-workflow-service.mjs +631 -0
  13. package/monitor.mjs +18 -103
  14. package/package.json +21 -2
  15. package/primary-agent.mjs +32 -12
  16. package/session-tracker.mjs +68 -0
  17. package/setup-web-server.mjs +20 -10
  18. package/setup.mjs +376 -83
  19. package/startup-service.mjs +51 -6
  20. package/stream-resilience.mjs +17 -7
  21. package/ui/app.js +164 -4
  22. package/ui/components/agent-selector.js +145 -1
  23. package/ui/components/chat-view.js +161 -15
  24. package/ui/components/session-list.js +2 -2
  25. package/ui/components/shared.js +188 -15
  26. package/ui/modules/icons.js +13 -0
  27. package/ui/modules/utils.js +44 -0
  28. package/ui/modules/voice-client-sdk.js +733 -0
  29. package/ui/modules/voice-overlay.js +128 -15
  30. package/ui/modules/voice.js +15 -6
  31. package/ui/setup.html +281 -81
  32. package/ui/styles/components.css +99 -3
  33. package/ui/styles/sessions.css +122 -14
  34. package/ui/styles.css +14 -0
  35. package/ui/tabs/agents.js +1 -1
  36. package/ui/tabs/chat.js +123 -14
  37. package/ui/tabs/control.js +16 -22
  38. package/ui/tabs/dashboard.js +85 -8
  39. package/ui/tabs/library.js +113 -17
  40. package/ui/tabs/settings.js +116 -2
  41. package/ui/tabs/tasks.js +388 -39
  42. package/ui/tabs/telemetry.js +0 -1
  43. package/ui/tabs/workflows.js +4 -0
  44. package/ui-server.mjs +400 -22
  45. package/update-check.mjs +41 -13
  46. package/voice-action-dispatcher.mjs +844 -0
  47. package/voice-agents-sdk.mjs +664 -0
  48. package/voice-auth-manager.mjs +164 -0
  49. package/voice-relay.mjs +1194 -0
  50. package/voice-tools.mjs +914 -0
  51. package/workflow-templates/agents.mjs +6 -2
  52. package/workflow-templates/github.mjs +154 -12
  53. package/workflow-templates.mjs +3 -0
  54. package/github-reconciler.mjs +0 -506
  55. package/merge-strategy.mjs +0 -1210
  56. package/pr-cleanup-daemon.mjs +0 -992
  57. package/workspace-reaper.mjs +0 -405
package/ui/setup.html CHANGED
@@ -715,6 +715,107 @@ function App() {
715
715
  const [azureOpenaiRealtimeEndpoint, setAzureOpenaiRealtimeEndpoint] = useState("");
716
716
  const [azureOpenaiRealtimeApiKey, setAzureOpenaiRealtimeApiKey] = useState("");
717
717
  const [azureOpenaiRealtimeDeployment, setAzureOpenaiRealtimeDeployment] = useState("gpt-4o-realtime-preview");
718
+ const [voiceProviders, setVoiceProviders] = useState([
719
+ {
720
+ id: Date.now(),
721
+ provider: voiceProvider,
722
+ model: voiceModel,
723
+ visionModel: voiceVisionModel,
724
+ voiceId,
725
+ azureDeployment: azureOpenaiRealtimeDeployment,
726
+ },
727
+ ]);
728
+
729
+ const VOICE_PROVIDER_MODEL_DEFAULTS = {
730
+ openai: { model: "gpt-4o-realtime-preview-2024-12-17", visionModel: "gpt-4.1-mini" },
731
+ azure: { model: "gpt-4o-realtime-preview-2024-12-17", visionModel: "gpt-4.1-mini" },
732
+ claude: { model: "claude-3-7-sonnet-latest", visionModel: "claude-3-7-sonnet-latest" },
733
+ gemini: { model: "gemini-2.5-pro", visionModel: "gemini-2.5-flash" },
734
+ fallback: { model: "", visionModel: "" },
735
+ };
736
+
737
+ const getVoiceProviderModelDefaults = (provider) =>
738
+ VOICE_PROVIDER_MODEL_DEFAULTS[String(provider || "fallback").toLowerCase()] || VOICE_PROVIDER_MODEL_DEFAULTS.fallback;
739
+
740
+ const normalizeVoiceProviderEntry = (entry = {}, fallback = {}) => {
741
+ const allowedProviders = ["openai", "azure", "claude", "gemini", "fallback"];
742
+ const provider = String(entry.provider || fallback.provider || "fallback").trim().toLowerCase();
743
+ const normalizedProvider = allowedProviders.includes(provider) ? provider : "fallback";
744
+ const defaults_ = getVoiceProviderModelDefaults(normalizedProvider);
745
+ const model = String(entry.model ?? fallback.model ?? defaults_.model ?? "").trim();
746
+ const visionModel = String(entry.visionModel ?? fallback.visionModel ?? defaults_.visionModel ?? "").trim();
747
+ const normalizedVoiceId = String(entry.voiceId ?? fallback.voiceId ?? "alloy").trim() || "alloy";
748
+ const normalizedAzureDeployment = String(
749
+ entry.azureDeployment ??
750
+ fallback.azureDeployment ??
751
+ (normalizedProvider === "azure" ? "gpt-4o-realtime-preview" : ""),
752
+ ).trim();
753
+ return {
754
+ id: entry.id || Date.now() + Math.random(),
755
+ provider: normalizedProvider,
756
+ model,
757
+ visionModel,
758
+ voiceId: normalizedVoiceId,
759
+ azureDeployment: normalizedAzureDeployment,
760
+ };
761
+ };
762
+
763
+ const normalizeVoiceProviders = (providers, fallback = {}) => {
764
+ const normalized = (Array.isArray(providers) ? providers : [])
765
+ .slice(0, 5)
766
+ .map((provider_) => normalizeVoiceProviderEntry(provider_, fallback));
767
+ if (normalized.length > 0) return normalized;
768
+ return [normalizeVoiceProviderEntry({}, fallback)];
769
+ };
770
+
771
+ const updateVoiceProviderRow = (idx, field, value) => {
772
+ setVoiceProviders((prev) => {
773
+ const list = normalizeVoiceProviders(prev);
774
+ const target = list[idx];
775
+ if (!target) return list;
776
+ if (field === "provider") {
777
+ const nextProvider = String(value || "fallback").trim().toLowerCase();
778
+ const defaults_ = getVoiceProviderModelDefaults(nextProvider);
779
+ list[idx] = normalizeVoiceProviderEntry({
780
+ ...target,
781
+ provider: nextProvider,
782
+ model: defaults_.model,
783
+ visionModel: defaults_.visionModel,
784
+ azureDeployment:
785
+ nextProvider === "azure"
786
+ ? String(target.azureDeployment || "gpt-4o-realtime-preview")
787
+ : "",
788
+ });
789
+ return list;
790
+ }
791
+ list[idx] = normalizeVoiceProviderEntry({ ...target, [field]: value });
792
+ return list;
793
+ });
794
+ };
795
+
796
+ const addVoiceProviderRow = () => {
797
+ setVoiceProviders((prev) => {
798
+ const list = normalizeVoiceProviders(prev);
799
+ if (list.length >= 5) return list;
800
+ const first = list[0] || normalizeVoiceProviderEntry();
801
+ return [
802
+ ...list,
803
+ normalizeVoiceProviderEntry({
804
+ provider: "fallback",
805
+ voiceId: first.voiceId || "alloy",
806
+ }),
807
+ ];
808
+ });
809
+ };
810
+
811
+ const removeVoiceProviderRow = (idx) => {
812
+ setVoiceProviders((prev) => {
813
+ const list = normalizeVoiceProviders(prev);
814
+ if (list.length <= 1) return list;
815
+ const next = list.filter((_, i) => i !== idx);
816
+ return next.length > 0 ? next : list;
817
+ });
818
+ };
718
819
 
719
820
  const getWorkflowProfileById = (profileId, profileList = workflowProfiles) =>
720
821
  (profileList || []).find((profile_) => profile_.id === profileId) || null;
@@ -1055,6 +1156,32 @@ function App() {
1055
1156
  if (env.AZURE_OPENAI_REALTIME_ENDPOINT) { setAzureOpenaiRealtimeEndpoint(env.AZURE_OPENAI_REALTIME_ENDPOINT); envLoaded = true; }
1056
1157
  if (env.AZURE_OPENAI_REALTIME_API_KEY) { setAzureOpenaiRealtimeApiKey(env.AZURE_OPENAI_REALTIME_API_KEY); envLoaded = true; }
1057
1158
  if (env.AZURE_OPENAI_REALTIME_DEPLOYMENT) { setAzureOpenaiRealtimeDeployment(env.AZURE_OPENAI_REALTIME_DEPLOYMENT); envLoaded = true; }
1159
+ const resolvedVoiceProvider = String(env.VOICE_PROVIDER || existingVoice.provider || d.voiceProvider || voiceProvider || "openai").trim().toLowerCase();
1160
+ const resolvedVoiceModel = String(env.VOICE_MODEL || existingVoice.model || d.voiceModel || voiceModel || "gpt-4o-realtime-preview-2024-12-17").trim();
1161
+ const resolvedVoiceVisionModel = String(env.VOICE_VISION_MODEL || existingVoice.visionModel || d.voiceVisionModel || voiceVisionModel || "gpt-4.1-mini").trim();
1162
+ const resolvedVoiceId = String(env.VOICE_ID || existingVoice.voiceId || d.voiceId || voiceId || "alloy").trim();
1163
+ const resolvedAzureDeployment = String(env.AZURE_OPENAI_REALTIME_DEPLOYMENT || existingVoice.azureDeployment || d.azureOpenaiRealtimeDeployment || azureOpenaiRealtimeDeployment || "gpt-4o-realtime-preview").trim();
1164
+ if (Array.isArray(existingVoice.providers) && existingVoice.providers.length > 0) {
1165
+ setVoiceProviders(
1166
+ normalizeVoiceProviders(existingVoice.providers, {
1167
+ provider: resolvedVoiceProvider,
1168
+ model: resolvedVoiceModel,
1169
+ visionModel: resolvedVoiceVisionModel,
1170
+ voiceId: resolvedVoiceId,
1171
+ azureDeployment: resolvedAzureDeployment,
1172
+ }),
1173
+ );
1174
+ } else {
1175
+ setVoiceProviders(
1176
+ normalizeVoiceProviders([], {
1177
+ provider: resolvedVoiceProvider,
1178
+ model: resolvedVoiceModel,
1179
+ visionModel: resolvedVoiceVisionModel,
1180
+ voiceId: resolvedVoiceId,
1181
+ azureDeployment: resolvedAzureDeployment,
1182
+ }),
1183
+ );
1184
+ }
1058
1185
  // Multi-workspace: load workspaces[] from existing config
1059
1186
  if (statusData.existingConfig?.workspaces?.length > 0) {
1060
1187
  setMultiWorkspaceEnabled(true);
@@ -1250,6 +1377,21 @@ function App() {
1250
1377
  setError(null);
1251
1378
  try {
1252
1379
  const filteredRepos = repos.filter((r) => r.trim());
1380
+ const normalizedVoiceProviders = normalizeVoiceProviders(voiceProviders, {
1381
+ provider: voiceProvider,
1382
+ model: voiceModel,
1383
+ visionModel: voiceVisionModel,
1384
+ voiceId,
1385
+ azureDeployment: azureOpenaiRealtimeDeployment,
1386
+ });
1387
+ const primaryVoiceProvider = normalizedVoiceProviders[0] || normalizeVoiceProviderEntry({
1388
+ provider: voiceProvider,
1389
+ model: voiceModel,
1390
+ visionModel: voiceVisionModel,
1391
+ voiceId,
1392
+ azureDeployment: azureOpenaiRealtimeDeployment,
1393
+ });
1394
+ const voiceFailoverMaxAttempts = Math.max(1, Math.min(normalizedVoiceProviders.length, 5));
1253
1395
  const result = await apiPost("apply", {
1254
1396
  env: {
1255
1397
  bosunHome,
@@ -1311,17 +1453,20 @@ function App() {
1311
1453
  copilotMcpConfig,
1312
1454
  // Voice assistant
1313
1455
  voiceEnabled,
1314
- voiceProvider,
1315
- voiceModel,
1316
- voiceVisionModel,
1317
- voiceId,
1456
+ voiceProvider: primaryVoiceProvider.provider || voiceProvider,
1457
+ voiceModel: primaryVoiceProvider.model || voiceModel,
1458
+ voiceVisionModel: primaryVoiceProvider.visionModel || voiceVisionModel,
1459
+ voiceId: primaryVoiceProvider.voiceId || voiceId,
1318
1460
  voiceTurnDetection,
1319
1461
  voiceFallbackMode,
1320
1462
  voiceDelegateExecutor,
1321
1463
  openaiRealtimeApiKey,
1322
1464
  azureOpenaiRealtimeEndpoint,
1323
1465
  azureOpenaiRealtimeApiKey,
1324
- azureOpenaiRealtimeDeployment,
1466
+ azureOpenaiRealtimeDeployment: primaryVoiceProvider.azureDeployment || azureOpenaiRealtimeDeployment,
1467
+ voiceProviders: normalizedVoiceProviders,
1468
+ voiceFailoverEnabled: true,
1469
+ voiceFailoverMaxAttempts,
1325
1470
  // Infrastructure
1326
1471
  containerEnabled,
1327
1472
  containerRuntime,
@@ -1367,6 +1512,25 @@ function App() {
1367
1512
  maxPersistedRuns: Number(workflowMaxPersistedRuns) || 200,
1368
1513
  maxConcurrentBranches: Number(workflowMaxConcurrentBranches) || 8,
1369
1514
  },
1515
+ voice: {
1516
+ enabled: voiceEnabled !== false,
1517
+ provider: primaryVoiceProvider.provider || voiceProvider,
1518
+ model: primaryVoiceProvider.model || voiceModel,
1519
+ visionModel: primaryVoiceProvider.visionModel || voiceVisionModel,
1520
+ voiceId: primaryVoiceProvider.voiceId || voiceId,
1521
+ turnDetection: voiceTurnDetection,
1522
+ fallbackMode: voiceFallbackMode,
1523
+ delegateExecutor: voiceDelegateExecutor,
1524
+ azureDeployment: primaryVoiceProvider.azureDeployment || azureOpenaiRealtimeDeployment,
1525
+ openaiApiKey: openaiRealtimeApiKey,
1526
+ azureEndpoint: azureOpenaiRealtimeEndpoint,
1527
+ azureApiKey: azureOpenaiRealtimeApiKey,
1528
+ providers: normalizedVoiceProviders.slice(0, 5),
1529
+ failover: {
1530
+ enabled: true,
1531
+ maxAttempts: voiceFailoverMaxAttempts,
1532
+ },
1533
+ },
1370
1534
  workspaces: multiWorkspaceEnabled
1371
1535
  ? workspaces.filter((ws) => ws.name.trim()).map((ws) => ({
1372
1536
  id: ws.id,
@@ -2693,52 +2857,90 @@ function App() {
2693
2857
  <div class="hint">Allows live voice/video calls from chat. Tier 2 browser fallback works without cloud keys.</div>
2694
2858
  </div>
2695
2859
  ${voiceEnabled && html`
2696
- <div class="executor-grid">
2697
- <div class="form-group">
2698
- <label>Voice Provider</label>
2699
- <select value=${voiceProvider} onchange=${(e) => setVoiceProvider(e.target.value)}>
2700
- <option value="auto">Auto Detect</option>
2701
- <option value="openai">OpenAI Realtime</option>
2702
- <option value="azure">Azure OpenAI Realtime</option>
2703
- <option value="claude">Claude (fallback + Claude vision)</option>
2704
- <option value="gemini">Gemini (fallback + Gemini vision)</option>
2705
- <option value="fallback">Browser Fallback Only</option>
2706
- </select>
2707
- </div>
2708
- <div class="form-group">
2709
- <label>Voice Persona</label>
2710
- <select value=${voiceId} onchange=${(e) => setVoiceId(e.target.value)}>
2711
- <option value="alloy">alloy</option>
2712
- <option value="ash">ash</option>
2713
- <option value="ballad">ballad</option>
2714
- <option value="coral">coral</option>
2715
- <option value="echo">echo</option>
2716
- <option value="fable">fable</option>
2717
- <option value="nova">nova</option>
2718
- <option value="onyx">onyx</option>
2719
- <option value="sage">sage</option>
2720
- <option value="shimmer">shimmer</option>
2721
- <option value="verse">verse</option>
2722
- </select>
2723
- </div>
2724
- <div class="form-group">
2725
- <label>Realtime Voice Model</label>
2726
- <input
2727
- type="text"
2728
- value=${voiceModel}
2729
- oninput=${(e) => setVoiceModel(e.target.value)}
2730
- placeholder="gpt-4o-realtime-preview-2024-12-17"
2731
- />
2732
- </div>
2733
- <div class="form-group">
2734
- <label>Vision Model</label>
2735
- <input
2736
- type="text"
2737
- value=${voiceVisionModel}
2738
- oninput=${(e) => setVoiceVisionModel(e.target.value)}
2739
- placeholder="gpt-4.1-mini"
2740
- />
2860
+ <div class="form-group">
2861
+ <label>Voice Providers (priority order)</label>
2862
+ <div class="hint">Configure up to 5 providers. Bosun will try them in order.</div>
2863
+ </div>
2864
+ ${(voiceProviders || []).slice(0, 5).map((providerRow, idx) => html`
2865
+ <div style="border:1px solid var(--border-primary);border-radius:var(--radius-sm);padding:12px;margin-bottom:10px">
2866
+ <div class="executor-grid">
2867
+ <div class="form-group">
2868
+ <label>Provider ${idx + 1}</label>
2869
+ <select value=${providerRow.provider} onchange=${(e) => updateVoiceProviderRow(idx, "provider", e.target.value)}>
2870
+ <option value="openai">OpenAI Realtime</option>
2871
+ <option value="azure">Azure OpenAI Realtime</option>
2872
+ <option value="claude">Claude</option>
2873
+ <option value="gemini">Gemini</option>
2874
+ <option value="fallback">Browser Fallback</option>
2875
+ </select>
2876
+ </div>
2877
+ <div class="form-group">
2878
+ <label>Model</label>
2879
+ <input
2880
+ type="text"
2881
+ value=${providerRow.model}
2882
+ oninput=${(e) => updateVoiceProviderRow(idx, "model", e.target.value)}
2883
+ placeholder="Provider model"
2884
+ />
2885
+ </div>
2886
+ <div class="form-group">
2887
+ <label>Vision Model</label>
2888
+ <input
2889
+ type="text"
2890
+ value=${providerRow.visionModel}
2891
+ oninput=${(e) => updateVoiceProviderRow(idx, "visionModel", e.target.value)}
2892
+ placeholder="Provider vision model"
2893
+ />
2894
+ </div>
2895
+ <div class="form-group">
2896
+ <label>Voice Persona</label>
2897
+ <select value=${providerRow.voiceId} onchange=${(e) => updateVoiceProviderRow(idx, "voiceId", e.target.value)}>
2898
+ <option value="alloy">alloy</option>
2899
+ <option value="ash">ash</option>
2900
+ <option value="ballad">ballad</option>
2901
+ <option value="coral">coral</option>
2902
+ <option value="echo">echo</option>
2903
+ <option value="fable">fable</option>
2904
+ <option value="nova">nova</option>
2905
+ <option value="onyx">onyx</option>
2906
+ <option value="sage">sage</option>
2907
+ <option value="shimmer">shimmer</option>
2908
+ <option value="verse">verse</option>
2909
+ </select>
2910
+ </div>
2911
+ ${providerRow.provider === "azure" && html`
2912
+ <div class="form-group">
2913
+ <label>Azure Deployment</label>
2914
+ <input
2915
+ type="text"
2916
+ value=${providerRow.azureDeployment || ""}
2917
+ oninput=${(e) => updateVoiceProviderRow(idx, "azureDeployment", e.target.value)}
2918
+ placeholder="gpt-4o-realtime-preview"
2919
+ />
2920
+ </div>
2921
+ `}
2922
+ </div>
2923
+ <div style="display:flex;justify-content:flex-end">
2924
+ <button
2925
+ class="btn btn-sm btn-danger"
2926
+ onclick=${() => removeVoiceProviderRow(idx)}
2927
+ disabled=${(voiceProviders || []).length <= 1}
2928
+ >
2929
+ Remove
2930
+ </button>
2931
+ </div>
2741
2932
  </div>
2933
+ `)}
2934
+ <div style="display:flex;justify-content:flex-end;margin-bottom:12px">
2935
+ <button
2936
+ class="btn btn-sm"
2937
+ onclick=${addVoiceProviderRow}
2938
+ disabled=${(voiceProviders || []).length >= 5}
2939
+ >
2940
+ Add Provider
2941
+ </button>
2942
+ </div>
2943
+ <div class="executor-grid">
2742
2944
  <div class="form-group">
2743
2945
  <label>Turn Detection</label>
2744
2946
  <select value=${voiceTurnDetection} onchange=${(e) => setVoiceTurnDetection(e.target.value)}>
@@ -2765,38 +2967,25 @@ function App() {
2765
2967
  </select>
2766
2968
  </div>
2767
2969
  </div>
2768
- ${(voiceProvider === "auto" || voiceProvider === "openai") && html`
2970
+ <div class="form-group">
2971
+ <label>OpenAI Realtime API Key</label>
2972
+ <input
2973
+ type="password"
2974
+ value=${openaiRealtimeApiKey}
2975
+ oninput=${(e) => setOpenaiRealtimeApiKey(e.target.value)}
2976
+ placeholder="Optional - defaults to OPENAI_API_KEY"
2977
+ />
2978
+ <div class="hint">Leave blank to use OPENAI_API_KEY.</div>
2979
+ </div>
2980
+ <div class="executor-grid">
2769
2981
  <div class="form-group">
2770
- <label>OpenAI Realtime API Key</label>
2982
+ <label>Azure Realtime Endpoint</label>
2771
2983
  <input
2772
- type="password"
2773
- value=${openaiRealtimeApiKey}
2774
- oninput=${(e) => setOpenaiRealtimeApiKey(e.target.value)}
2775
- placeholder="Optional - defaults to OPENAI_API_KEY"
2984
+ type="text"
2985
+ value=${azureOpenaiRealtimeEndpoint}
2986
+ oninput=${(e) => setAzureOpenaiRealtimeEndpoint(e.target.value)}
2987
+ placeholder="https://<resource>.openai.azure.com"
2776
2988
  />
2777
- <div class="hint">Leave blank to use OPENAI_API_KEY.</div>
2778
- </div>
2779
- `}
2780
- ${(voiceProvider === "auto" || voiceProvider === "azure") && html`
2781
- <div class="executor-grid">
2782
- <div class="form-group">
2783
- <label>Azure Realtime Endpoint</label>
2784
- <input
2785
- type="text"
2786
- value=${azureOpenaiRealtimeEndpoint}
2787
- oninput=${(e) => setAzureOpenaiRealtimeEndpoint(e.target.value)}
2788
- placeholder="https://<resource>.openai.azure.com"
2789
- />
2790
- </div>
2791
- <div class="form-group">
2792
- <label>Azure Realtime Deployment</label>
2793
- <input
2794
- type="text"
2795
- value=${azureOpenaiRealtimeDeployment}
2796
- oninput=${(e) => setAzureOpenaiRealtimeDeployment(e.target.value)}
2797
- placeholder="gpt-4o-realtime-preview"
2798
- />
2799
- </div>
2800
2989
  </div>
2801
2990
  <div class="form-group">
2802
2991
  <label>Azure Realtime API Key</label>
@@ -2807,7 +2996,7 @@ function App() {
2807
2996
  placeholder="Optional - defaults to AZURE_OPENAI_API_KEY"
2808
2997
  />
2809
2998
  </div>
2810
- `}
2999
+ </div>
2811
3000
  `}
2812
3001
  <//>
2813
3002
 
@@ -2879,6 +3068,17 @@ function App() {
2879
3068
 
2880
3069
  const StepReview = () => {
2881
3070
  const filteredRepos = repos.filter((r) => r.trim());
3071
+ const reviewVoiceProviders = normalizeVoiceProviders(voiceProviders, {
3072
+ provider: voiceProvider,
3073
+ model: voiceModel,
3074
+ visionModel: voiceVisionModel,
3075
+ voiceId,
3076
+ azureDeployment: azureOpenaiRealtimeDeployment,
3077
+ });
3078
+ const voiceProviderChainSummary = reviewVoiceProviders
3079
+ .map((provider_) => provider_.provider)
3080
+ .filter(Boolean)
3081
+ .join(" -> ");
2882
3082
 
2883
3083
  return html`
2884
3084
  <h2>Review & Apply</h2>
@@ -2919,7 +3119,7 @@ function App() {
2919
3119
  <th>Voice</th>
2920
3120
  <td>
2921
3121
  ${voiceEnabled
2922
- ? `${voiceProvider} (${voiceFallbackMode} fallback)`
3122
+ ? `${voiceProviderChainSummary || voiceProvider} (${voiceFallbackMode} fallback)`
2923
3123
  : "Disabled"}
2924
3124
  </td>
2925
3125
  </tr>
@@ -1,5 +1,70 @@
1
1
  /* ─── Component Styles — iOS-style Clean Design ─── */
2
2
 
3
+ /* ─── Telemetry Tab ─── */
4
+ .telemetry-tab {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: 12px;
8
+ }
9
+
10
+ .telemetry-grid {
11
+ display: grid;
12
+ grid-template-columns: repeat(2, minmax(0, 1fr));
13
+ gap: 12px;
14
+ align-items: stretch;
15
+ }
16
+
17
+ .telemetry-grid > .card {
18
+ margin-bottom: 0;
19
+ min-height: 240px;
20
+ display: flex;
21
+ flex-direction: column;
22
+ }
23
+
24
+ .telemetry-grid > .card .empty-state {
25
+ flex: 1;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ }
30
+
31
+ .telemetry-list {
32
+ list-style: none;
33
+ margin: 0;
34
+ padding: 0;
35
+ }
36
+
37
+ .telemetry-list li {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ gap: 10px;
42
+ padding: 8px 0;
43
+ border-bottom: 1px solid var(--border);
44
+ }
45
+
46
+ .telemetry-list li:last-child {
47
+ border-bottom: 0;
48
+ }
49
+
50
+ .telemetry-label {
51
+ min-width: 0;
52
+ overflow: hidden;
53
+ text-overflow: ellipsis;
54
+ white-space: nowrap;
55
+ }
56
+
57
+ .telemetry-count {
58
+ font-weight: 600;
59
+ color: var(--text-primary);
60
+ }
61
+
62
+ @media (max-width: 960px) {
63
+ .telemetry-grid {
64
+ grid-template-columns: 1fr;
65
+ }
66
+ }
67
+
3
68
  /* ─── Cards ─── */
4
69
  .card {
5
70
  background: var(--bg-card);
@@ -3262,9 +3327,9 @@ select.input {
3262
3327
 
3263
3328
  /* ─── Control Unit (sticky) ─── */
3264
3329
  .control-unit-card {
3265
- position: sticky;
3266
- top: 0;
3267
- z-index: 18;
3330
+ position: relative;
3331
+ top: auto;
3332
+ z-index: auto;
3268
3333
  background: var(--bg-card);
3269
3334
  }
3270
3335
 
@@ -3496,6 +3561,31 @@ select.input {
3496
3561
  color: #fff;
3497
3562
  }
3498
3563
 
3564
+ /* ─── Shared Save/Discard Bar ─── */
3565
+ .ve-save-discard-bar {
3566
+ display: flex;
3567
+ align-items: center;
3568
+ justify-content: space-between;
3569
+ gap: 10px;
3570
+ margin-top: 12px;
3571
+ padding: 10px 12px;
3572
+ border: 1px solid var(--border);
3573
+ border-radius: var(--radius-lg);
3574
+ background: var(--bg-card);
3575
+ }
3576
+
3577
+ .ve-save-discard-message {
3578
+ font-size: 12px;
3579
+ color: var(--text-secondary);
3580
+ }
3581
+
3582
+ .ve-save-discard-actions {
3583
+ display: flex;
3584
+ gap: 8px;
3585
+ align-items: center;
3586
+ flex-wrap: wrap;
3587
+ }
3588
+
3499
3589
  /* ─── Toggle disabled ─── */
3500
3590
  .toggle-wrap.disabled {
3501
3591
  opacity: 0.4;
@@ -3690,6 +3780,12 @@ select.input {
3690
3780
  display: flex;
3691
3781
  flex-direction: column;
3692
3782
  gap: 16px;
3783
+ min-height: 0;
3784
+ }
3785
+
3786
+ .control-main .card,
3787
+ .control-side .card {
3788
+ overflow: visible;
3693
3789
  }
3694
3790
 
3695
3791
  .control-hero {