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
package/ui/setup.html CHANGED
@@ -1006,6 +1006,9 @@ function App() {
1006
1006
  const [voiceTurnDetection, setVoiceTurnDetection] = useState("server_vad");
1007
1007
  const [voiceFallbackMode, setVoiceFallbackMode] = useState("browser");
1008
1008
  const [voiceDelegateExecutor, setVoiceDelegateExecutor] = useState("codex-sdk");
1009
+ const [voiceTranscriptionEnabled, setVoiceTranscriptionEnabled] = useState(true);
1010
+ const [voiceTranscriptionModel, setVoiceTranscriptionModel] = useState("gpt-4o-transcribe");
1011
+ const [voiceAzureTranscriptionEnabled, setVoiceAzureTranscriptionEnabled] = useState(false);
1009
1012
  const [openaiRealtimeApiKey, setOpenaiRealtimeApiKey] = useState("");
1010
1013
  const [azureOpenaiRealtimeEndpoint, setAzureOpenaiRealtimeEndpoint] = useState("");
1011
1014
  const [azureOpenaiRealtimeApiKey, setAzureOpenaiRealtimeApiKey] = useState("");
@@ -1262,6 +1265,9 @@ function App() {
1262
1265
  const defaultModel = provider === "custom"
1263
1266
  ? (ALL_ENDPOINT_MODEL_OPTIONS[0] || "gpt-realtime-1.5")
1264
1267
  : (getVoiceProviderModelDefaults(provider).model || "gpt-realtime-1.5");
1268
+ const transcriptionEnabled = ep.transcriptionEnabled == null
1269
+ ? provider !== "azure"
1270
+ : ep.transcriptionEnabled !== false;
1265
1271
  return {
1266
1272
  id: ep.id || `ep-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1267
1273
  name: String(ep.name || "").trim(),
@@ -1274,6 +1280,8 @@ function App() {
1274
1280
  weight: typeof ep.weight === "number" ? ep.weight : 100,
1275
1281
  enabled: ep.enabled !== false,
1276
1282
  visionModel: String(ep.visionModel || "").trim(),
1283
+ transcriptionModel: String(ep.transcriptionModel || "").trim(),
1284
+ transcriptionEnabled,
1277
1285
  authSource,
1278
1286
  };
1279
1287
  };
@@ -1298,6 +1306,7 @@ function App() {
1298
1306
  ? String(next.endpoint || "")
1299
1307
  : getDefaultVoiceEndpointUrl(nextProvider, next.authSource || "apiKey");
1300
1308
  if (nextProvider !== "azure") next.deployment = "";
1309
+ next.transcriptionEnabled = nextProvider === "azure" ? false : true;
1301
1310
  }
1302
1311
  if (field === "authSource" && !isEndpointUrlEditable(next.provider)) {
1303
1312
  next.endpoint = getDefaultVoiceEndpointUrl(next.provider, String(value || "apiKey"));
@@ -1550,6 +1559,9 @@ function App() {
1550
1559
  if (d.voiceTurnDetection) { setVoiceTurnDetection(d.voiceTurnDetection); }
1551
1560
  if (d.voiceFallbackMode) { setVoiceFallbackMode(d.voiceFallbackMode); }
1552
1561
  if (d.voiceDelegateExecutor) { setVoiceDelegateExecutor(d.voiceDelegateExecutor); }
1562
+ if (d.voiceTranscriptionEnabled !== undefined) { setVoiceTranscriptionEnabled(d.voiceTranscriptionEnabled !== false); }
1563
+ if (d.voiceTranscriptionModel) { setVoiceTranscriptionModel(d.voiceTranscriptionModel); }
1564
+ if (d.voiceAzureTranscriptionEnabled !== undefined) { setVoiceAzureTranscriptionEnabled(d.voiceAzureTranscriptionEnabled === true); }
1553
1565
  if (d.openaiRealtimeApiKey) { setOpenaiRealtimeApiKey(d.openaiRealtimeApiKey); }
1554
1566
  if (d.azureOpenaiRealtimeEndpoint) { setAzureOpenaiRealtimeEndpoint(d.azureOpenaiRealtimeEndpoint); }
1555
1567
  if (d.azureOpenaiRealtimeApiKey) { setAzureOpenaiRealtimeApiKey(d.azureOpenaiRealtimeApiKey); }
@@ -1574,6 +1586,9 @@ function App() {
1574
1586
  if (existingVoice.turnDetection) { setVoiceTurnDetection(String(existingVoice.turnDetection)); envLoaded = true; }
1575
1587
  if (existingVoice.fallbackMode) { setVoiceFallbackMode(String(existingVoice.fallbackMode)); envLoaded = true; }
1576
1588
  if (existingVoice.delegateExecutor) { setVoiceDelegateExecutor(String(existingVoice.delegateExecutor)); envLoaded = true; }
1589
+ if (existingVoice.transcriptionEnabled != null) { setVoiceTranscriptionEnabled(existingVoice.transcriptionEnabled !== false); envLoaded = true; }
1590
+ if (existingVoice.transcriptionModel) { setVoiceTranscriptionModel(String(existingVoice.transcriptionModel)); envLoaded = true; }
1591
+ if (existingVoice.azureTranscriptionEnabled != null) { setVoiceAzureTranscriptionEnabled(existingVoice.azureTranscriptionEnabled === true); envLoaded = true; }
1577
1592
  if (existingVoice.openaiApiKey) { setOpenaiRealtimeApiKey(String(existingVoice.openaiApiKey)); envLoaded = true; }
1578
1593
  if (existingVoice.azureEndpoint) { setAzureOpenaiRealtimeEndpoint(String(existingVoice.azureEndpoint)); envLoaded = true; }
1579
1594
  if (existingVoice.azureApiKey) { setAzureOpenaiRealtimeApiKey(String(existingVoice.azureApiKey)); envLoaded = true; }
@@ -1586,10 +1601,29 @@ function App() {
1586
1601
  // Auto-convert legacy flat fields to endpoint cards
1587
1602
  const legacyEndpoints = [];
1588
1603
  if (existingVoice.azureEndpoint || existingVoice.azureApiKey) {
1589
- legacyEndpoints.push(normalizeVoiceEndpoint({ name: "azure-primary", provider: "azure", endpoint: existingVoice.azureEndpoint || "", deployment: existingVoice.azureDeployment || "gpt-realtime-1.5", apiKey: existingVoice.azureApiKey || "", role: "primary", weight: 100 }));
1604
+ legacyEndpoints.push(normalizeVoiceEndpoint({
1605
+ name: "azure-primary",
1606
+ provider: "azure",
1607
+ endpoint: existingVoice.azureEndpoint || "",
1608
+ deployment: existingVoice.azureDeployment || "gpt-realtime-1.5",
1609
+ apiKey: existingVoice.azureApiKey || "",
1610
+ role: "primary",
1611
+ weight: 100,
1612
+ transcriptionEnabled: existingVoice.azureTranscriptionEnabled === true,
1613
+ transcriptionModel: existingVoice.transcriptionModel || "gpt-4o-transcribe",
1614
+ }));
1590
1615
  }
1591
1616
  if (existingVoice.openaiApiKey) {
1592
- legacyEndpoints.push(normalizeVoiceEndpoint({ name: "openai-primary", provider: "openai", apiKey: existingVoice.openaiApiKey || "", model: existingVoice.model || "gpt-realtime-1.5", role: legacyEndpoints.length === 0 ? "primary" : "backup", weight: legacyEndpoints.length === 0 ? 100 : 50 }));
1617
+ legacyEndpoints.push(normalizeVoiceEndpoint({
1618
+ name: "openai-primary",
1619
+ provider: "openai",
1620
+ apiKey: existingVoice.openaiApiKey || "",
1621
+ model: existingVoice.model || "gpt-realtime-1.5",
1622
+ role: legacyEndpoints.length === 0 ? "primary" : "backup",
1623
+ weight: legacyEndpoints.length === 0 ? 100 : 50,
1624
+ transcriptionEnabled: existingVoice.transcriptionEnabled !== false,
1625
+ transcriptionModel: existingVoice.transcriptionModel || "gpt-4o-transcribe",
1626
+ }));
1593
1627
  }
1594
1628
  if (legacyEndpoints.length > 0) setVoiceEndpoints(legacyEndpoints);
1595
1629
  }
@@ -1723,6 +1757,9 @@ function App() {
1723
1757
  if (env.VOICE_TURN_DETECTION) { setVoiceTurnDetection(env.VOICE_TURN_DETECTION); envLoaded = true; }
1724
1758
  if (env.VOICE_FALLBACK_MODE) { setVoiceFallbackMode(env.VOICE_FALLBACK_MODE); envLoaded = true; }
1725
1759
  if (env.VOICE_DELEGATE_EXECUTOR) { setVoiceDelegateExecutor(env.VOICE_DELEGATE_EXECUTOR); envLoaded = true; }
1760
+ if (env.VOICE_TRANSCRIPTION_ENABLED !== undefined) { setVoiceTranscriptionEnabled(env.VOICE_TRANSCRIPTION_ENABLED !== "false"); envLoaded = true; }
1761
+ if (env.VOICE_TRANSCRIPTION_MODEL) { setVoiceTranscriptionModel(env.VOICE_TRANSCRIPTION_MODEL); envLoaded = true; }
1762
+ if (env.VOICE_AZURE_TRANSCRIPTION_ENABLED !== undefined) { setVoiceAzureTranscriptionEnabled(env.VOICE_AZURE_TRANSCRIPTION_ENABLED === "true"); envLoaded = true; }
1726
1763
  if (env.OPENAI_REALTIME_API_KEY) { setOpenaiRealtimeApiKey(env.OPENAI_REALTIME_API_KEY); envLoaded = true; }
1727
1764
  if (env.AZURE_OPENAI_REALTIME_ENDPOINT) { setAzureOpenaiRealtimeEndpoint(env.AZURE_OPENAI_REALTIME_ENDPOINT); envLoaded = true; }
1728
1765
  if (env.AZURE_OPENAI_REALTIME_API_KEY) { setAzureOpenaiRealtimeApiKey(env.AZURE_OPENAI_REALTIME_API_KEY); envLoaded = true; }
@@ -1730,10 +1767,29 @@ function App() {
1730
1767
  // Build voiceEndpoints from env vars if not already populated from config
1731
1768
  const envEps = [];
1732
1769
  if (env.AZURE_OPENAI_REALTIME_ENDPOINT || env.AZURE_OPENAI_REALTIME_API_KEY) {
1733
- envEps.push(normalizeVoiceEndpoint({ name: "azure-primary", provider: "azure", endpoint: env.AZURE_OPENAI_REALTIME_ENDPOINT || "", deployment: env.AZURE_OPENAI_REALTIME_DEPLOYMENT || "gpt-realtime-1.5", apiKey: env.AZURE_OPENAI_REALTIME_API_KEY || "", role: "primary", weight: 100 }));
1770
+ envEps.push(normalizeVoiceEndpoint({
1771
+ name: "azure-primary",
1772
+ provider: "azure",
1773
+ endpoint: env.AZURE_OPENAI_REALTIME_ENDPOINT || "",
1774
+ deployment: env.AZURE_OPENAI_REALTIME_DEPLOYMENT || "gpt-realtime-1.5",
1775
+ apiKey: env.AZURE_OPENAI_REALTIME_API_KEY || "",
1776
+ role: "primary",
1777
+ weight: 100,
1778
+ transcriptionEnabled: env.VOICE_AZURE_TRANSCRIPTION_ENABLED === "true",
1779
+ transcriptionModel: env.VOICE_TRANSCRIPTION_MODEL || "gpt-4o-transcribe",
1780
+ }));
1734
1781
  }
1735
1782
  if (env.OPENAI_REALTIME_API_KEY) {
1736
- envEps.push(normalizeVoiceEndpoint({ name: "openai-primary", provider: "openai", apiKey: env.OPENAI_REALTIME_API_KEY, model: env.VOICE_MODEL || "gpt-realtime-1.5", role: envEps.length === 0 ? "primary" : "backup", weight: envEps.length === 0 ? 100 : 50 }));
1783
+ envEps.push(normalizeVoiceEndpoint({
1784
+ name: "openai-primary",
1785
+ provider: "openai",
1786
+ apiKey: env.OPENAI_REALTIME_API_KEY,
1787
+ model: env.VOICE_MODEL || "gpt-realtime-1.5",
1788
+ role: envEps.length === 0 ? "primary" : "backup",
1789
+ weight: envEps.length === 0 ? 100 : 50,
1790
+ transcriptionEnabled: env.VOICE_TRANSCRIPTION_ENABLED !== "false",
1791
+ transcriptionModel: env.VOICE_TRANSCRIPTION_MODEL || "gpt-4o-transcribe",
1792
+ }));
1737
1793
  }
1738
1794
  if (envEps.length > 0) { setVoiceEndpoints((prev) => (prev.length > 0 ? prev : envEps)); envLoaded = true; }
1739
1795
  const resolvedVoiceProvider = String(env.VOICE_PROVIDER || existingVoice.provider || d.voiceProvider || voiceProvider || "openai").trim().toLowerCase();
@@ -2093,6 +2149,9 @@ function App() {
2093
2149
  voiceTurnDetection,
2094
2150
  voiceFallbackMode,
2095
2151
  voiceDelegateExecutor,
2152
+ voiceTranscriptionEnabled,
2153
+ voiceTranscriptionModel,
2154
+ voiceAzureTranscriptionEnabled,
2096
2155
  openaiRealtimeApiKey,
2097
2156
  azureOpenaiRealtimeEndpoint,
2098
2157
  azureOpenaiRealtimeApiKey,
@@ -2167,12 +2226,29 @@ function App() {
2167
2226
  turnDetection: voiceTurnDetection,
2168
2227
  fallbackMode: voiceFallbackMode,
2169
2228
  delegateExecutor: voiceDelegateExecutor,
2229
+ transcriptionEnabled: voiceTranscriptionEnabled !== false,
2230
+ transcriptionModel: String(voiceTranscriptionModel || "").trim() || "gpt-4o-transcribe",
2231
+ azureTranscriptionEnabled: voiceAzureTranscriptionEnabled === true,
2170
2232
  azureDeployment: primaryAzureEp?.deployment || primaryVoiceProvider.azureDeployment || azureOpenaiRealtimeDeployment,
2171
2233
  openaiApiKey: primaryOpenAIEp?.apiKey || openaiRealtimeApiKey,
2172
2234
  azureEndpoint: primaryAzureEp?.endpoint || azureOpenaiRealtimeEndpoint,
2173
2235
  azureApiKey: primaryAzureEp?.apiKey || azureOpenaiRealtimeApiKey,
2174
2236
  providers: normalizedVoiceProviders.slice(0, 5),
2175
- voiceEndpoints: voiceEndpoints.filter((ep) => ep.endpoint || ep.apiKey || ep.deployment || ep.model).map((ep) => ({ id: ep.id, name: ep.name, provider: ep.provider, endpoint: ep.endpoint, deployment: ep.deployment, apiKey: ep.apiKey, model: ep.model, visionModel: ep.visionModel, role: ep.role, weight: ep.weight, enabled: ep.enabled })),
2237
+ voiceEndpoints: voiceEndpoints.filter((ep) => ep.endpoint || ep.apiKey || ep.deployment || ep.model).map((ep) => ({
2238
+ id: ep.id,
2239
+ name: ep.name,
2240
+ provider: ep.provider,
2241
+ endpoint: ep.endpoint,
2242
+ deployment: ep.deployment,
2243
+ apiKey: ep.apiKey,
2244
+ model: ep.model,
2245
+ visionModel: ep.visionModel,
2246
+ transcriptionModel: ep.transcriptionModel,
2247
+ transcriptionEnabled: ep.transcriptionEnabled !== false,
2248
+ role: ep.role,
2249
+ weight: ep.weight,
2250
+ enabled: ep.enabled,
2251
+ })),
2176
2252
  failover: {
2177
2253
  enabled: true,
2178
2254
  maxAttempts: voiceFailoverMaxAttempts,
@@ -3781,6 +3857,40 @@ function App() {
3781
3857
  </select>
3782
3858
  <div class="hint">Pick the same executor family you trust for task execution to keep behavior consistent.</div>
3783
3859
  </div>
3860
+ <div class="form-group">
3861
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
3862
+ <input
3863
+ type="checkbox"
3864
+ checked=${voiceTranscriptionEnabled}
3865
+ onchange=${(e) => setVoiceTranscriptionEnabled(e.target.checked)}
3866
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer"
3867
+ />
3868
+ Enable Input Transcription (OpenAI-compatible)
3869
+ </label>
3870
+ <div class="hint">Controls VOICE_TRANSCRIPTION_ENABLED for default realtime sessions.</div>
3871
+ </div>
3872
+ <div class="form-group">
3873
+ <label>Transcription Model</label>
3874
+ <input
3875
+ type="text"
3876
+ value=${voiceTranscriptionModel}
3877
+ oninput=${(e) => setVoiceTranscriptionModel(e.target.value)}
3878
+ placeholder="gpt-4o-transcribe"
3879
+ />
3880
+ <div class="hint">Controls VOICE_TRANSCRIPTION_MODEL for OpenAI/Azure endpoint defaults.</div>
3881
+ </div>
3882
+ <div class="form-group">
3883
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
3884
+ <input
3885
+ type="checkbox"
3886
+ checked=${voiceAzureTranscriptionEnabled}
3887
+ onchange=${(e) => setVoiceAzureTranscriptionEnabled(e.target.checked)}
3888
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer"
3889
+ />
3890
+ Enable Azure Input Transcription
3891
+ </label>
3892
+ <div class="hint">Controls VOICE_AZURE_TRANSCRIPTION_ENABLED. Default is OFF to reduce Azure item-level transcription errors.</div>
3893
+ </div>
3784
3894
  </div>
3785
3895
  <div class="form-group">
3786
3896
  <label style="font-weight:600;display:block;margin-bottom:4px">Voice Endpoints</label>
@@ -3850,9 +3960,14 @@ function App() {
3850
3960
  <input type="text" value=${ep.endpoint} oninput=${(e) => updateVoiceEndpoint(i, "endpoint", e.target.value)} placeholder="https://&lt;resource&gt;.openai.azure.com" />
3851
3961
  </div>
3852
3962
  <div class="form-group">
3853
- <label>Deployment</label>
3854
- <input type="text" value=${ep.deployment} oninput=${(e) => updateVoiceEndpoint(i, "deployment", e.target.value)} placeholder="gpt-realtime-1.5" />
3855
- <div class="hint">Connectivity tests use the endpoint URL exactly as entered. If you provide only a host, Bosun appends the default Azure OpenAI probe route.</div>
3963
+ <label>Deployment Name</label>
3964
+ <input type="text" value=${ep.deployment} oninput=${(e) => updateVoiceEndpoint(i, "deployment", e.target.value)} placeholder="my-gpt-4o-realtime" />
3965
+ <div class="hint">The deployment name from Azure AI Foundry (not the model name). Find it under your resource Deployments. Leave empty to test credentials only.</div>
3966
+ </div>
3967
+ <div class="form-group">
3968
+ <label>Audio Model (Realtime)</label>
3969
+ <input type="text" value=${ep.model} oninput=${(e) => updateVoiceEndpoint(i, "model", e.target.value)} placeholder="gpt-4o-realtime-preview" />
3970
+ <div class="hint">The underlying model name (e.g. gpt-4o-realtime-preview). Used at runtime.</div>
3856
3971
  </div>
3857
3972
  `}
3858
3973
  ${ep.provider === "custom" && html`
@@ -3933,6 +4048,34 @@ function App() {
3933
4048
  <input type="text" value=${ep.visionModel || ""} oninput=${(e) => updateVoiceEndpoint(i, "visionModel", e.target.value)} placeholder="e.g. gpt-4.1-nano" />
3934
4049
  <div class="hint">Optional model for vision/image tasks on this endpoint.</div>
3935
4050
  </div>
4051
+ ${(ep.provider === "openai" || ep.provider === "azure") && html`
4052
+ <div class="form-group">
4053
+ <label>Transcription Model</label>
4054
+ <input
4055
+ type="text"
4056
+ value=${ep.transcriptionModel || ""}
4057
+ oninput=${(e) => updateVoiceEndpoint(i, "transcriptionModel", e.target.value)}
4058
+ placeholder="gpt-4o-transcribe"
4059
+ />
4060
+ <div class="hint">Leave blank to use global VOICE_TRANSCRIPTION_MODEL.</div>
4061
+ </div>
4062
+ <div class="form-group">
4063
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
4064
+ <input
4065
+ type="checkbox"
4066
+ checked=${ep.transcriptionEnabled !== false}
4067
+ onchange=${(e) => updateVoiceEndpoint(i, "transcriptionEnabled", e.target.checked)}
4068
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer"
4069
+ />
4070
+ Enable Input Transcription
4071
+ </label>
4072
+ <div class="hint">
4073
+ ${ep.provider === "azure"
4074
+ ? "Azure defaults to OFF unless explicitly enabled."
4075
+ : "OpenAI defaults to ON unless explicitly disabled."}
4076
+ </div>
4077
+ </div>
4078
+ `}
3936
4079
  </div>
3937
4080
  <!-- Test Connection -->
3938
4081
  <div style="display:flex;align-items:center;gap:10px;margin-top:8px">
@@ -4333,4 +4476,3 @@ render(html`<${App} />`, document.getElementById("app"));
4333
4476
  </body>
4334
4477
  <script defer src="https://cloud.umami.is/script.js" data-website-id="24c5d605-7f25-4be5-875e-25c8f3cb4059"></script>
4335
4478
  </html>
4336
-
package/ui/styles.css CHANGED
@@ -392,3 +392,261 @@
392
392
  color: var(--text-hint);
393
393
  margin-top: 4px;
394
394
  }
395
+
396
+ /* ─── Usage Analytics Tab ─── */
397
+ .analytics-tab {
398
+ display: flex;
399
+ flex-direction: column;
400
+ gap: 18px;
401
+ }
402
+
403
+ .analytics-header {
404
+ flex-wrap: wrap;
405
+ gap: 10px;
406
+ }
407
+
408
+ .analytics-title-row {
409
+ display: flex;
410
+ align-items: baseline;
411
+ gap: 12px;
412
+ flex-wrap: wrap;
413
+ }
414
+
415
+ .analytics-since {
416
+ font-size: 12px;
417
+ color: var(--text-hint);
418
+ font-weight: 400;
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 4px;
422
+ }
423
+
424
+ .analytics-header-actions {
425
+ display: flex;
426
+ align-items: center;
427
+ gap: 8px;
428
+ flex-wrap: wrap;
429
+ }
430
+
431
+ /* Period toggle */
432
+ .analytics-period-toggle {
433
+ display: flex;
434
+ border: 1px solid var(--border);
435
+ border-radius: 8px;
436
+ overflow: hidden;
437
+ }
438
+
439
+ .analytics-period-btn {
440
+ background: none;
441
+ border: none;
442
+ color: var(--text-hint);
443
+ padding: 4px 12px;
444
+ font-size: 12px;
445
+ font-weight: 500;
446
+ cursor: pointer;
447
+ transition: background 0.15s, color 0.15s;
448
+ border-right: 1px solid var(--border);
449
+ }
450
+
451
+ .analytics-period-btn:last-child {
452
+ border-right: none;
453
+ }
454
+
455
+ .analytics-period-btn:hover {
456
+ background: var(--surface-hover);
457
+ color: var(--text-primary);
458
+ }
459
+
460
+ .analytics-period-btn.active {
461
+ background: var(--accent-muted);
462
+ color: var(--accent);
463
+ font-weight: 600;
464
+ }
465
+
466
+ /* Stats row */
467
+ .analytics-stats-row {
468
+ display: grid;
469
+ gap: 12px;
470
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
471
+ }
472
+
473
+ .analytics-stat {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 12px;
477
+ padding: 14px 16px;
478
+ background: var(--surface);
479
+ border: 1px solid var(--border);
480
+ border-radius: 12px;
481
+ transition: border-color 0.15s;
482
+ }
483
+
484
+ .analytics-stat:hover {
485
+ border-color: var(--accent-muted);
486
+ }
487
+
488
+ .analytics-stat-icon {
489
+ font-size: 20px;
490
+ opacity: 0.8;
491
+ flex-shrink: 0;
492
+ width: 28px;
493
+ text-align: center;
494
+ }
495
+
496
+ .analytics-stat-label {
497
+ font-size: 11px;
498
+ text-transform: uppercase;
499
+ letter-spacing: 0.08em;
500
+ color: var(--text-hint);
501
+ line-height: 1;
502
+ margin-bottom: 5px;
503
+ }
504
+
505
+ .analytics-stat-value {
506
+ font-size: 22px;
507
+ font-weight: 700;
508
+ color: var(--text-primary);
509
+ line-height: 1;
510
+ letter-spacing: -0.02em;
511
+ }
512
+
513
+ /* Trend chart card */
514
+ .analytics-trend-card {
515
+ overflow: hidden;
516
+ }
517
+
518
+ .analytics-trend-header {
519
+ display: flex;
520
+ align-items: center;
521
+ justify-content: space-between;
522
+ margin-bottom: 10px;
523
+ }
524
+
525
+ .analytics-trend-tabs {
526
+ display: flex;
527
+ gap: 4px;
528
+ }
529
+
530
+ .analytics-trend-tab {
531
+ background: none;
532
+ border: 1px solid var(--border);
533
+ border-radius: 6px;
534
+ color: var(--text-hint);
535
+ padding: 3px 10px;
536
+ font-size: 11px;
537
+ font-weight: 500;
538
+ cursor: pointer;
539
+ transition: all 0.15s;
540
+ }
541
+
542
+ .analytics-trend-tab:hover {
543
+ background: var(--surface-hover);
544
+ color: var(--text-primary);
545
+ }
546
+
547
+ .analytics-trend-tab.active {
548
+ background: var(--accent-muted);
549
+ border-color: var(--accent);
550
+ color: var(--accent);
551
+ }
552
+
553
+ /* Legend */
554
+ .analytics-legend {
555
+ margin-bottom: 8px;
556
+ }
557
+
558
+ .analytics-legend-group {
559
+ display: flex;
560
+ flex-wrap: wrap;
561
+ align-items: center;
562
+ gap: 10px;
563
+ font-size: 11px;
564
+ color: var(--text-secondary);
565
+ }
566
+
567
+ .analytics-legend-category {
568
+ font-weight: 700;
569
+ font-size: 10px;
570
+ letter-spacing: 0.08em;
571
+ text-transform: uppercase;
572
+ color: var(--text-hint);
573
+ }
574
+
575
+ .analytics-legend-item {
576
+ display: flex;
577
+ align-items: center;
578
+ gap: 5px;
579
+ font-size: 11px;
580
+ }
581
+
582
+ .analytics-legend-dot {
583
+ display: inline-block;
584
+ width: 8px;
585
+ height: 8px;
586
+ border-radius: 50%;
587
+ flex-shrink: 0;
588
+ }
589
+
590
+ /* SVG chart */
591
+ .analytics-trend-svg {
592
+ display: block;
593
+ width: 100%;
594
+ height: auto;
595
+ max-height: 160px;
596
+ margin-top: 4px;
597
+ }
598
+
599
+ /* Top-N grid */
600
+ .analytics-top-grid {
601
+ display: grid;
602
+ gap: 14px;
603
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
604
+ }
605
+
606
+ /* Bar chart */
607
+ .analytics-bar-list {
608
+ list-style: none;
609
+ padding: 0;
610
+ margin: 0;
611
+ display: flex;
612
+ flex-direction: column;
613
+ gap: 8px;
614
+ }
615
+
616
+ .analytics-bar-row {
617
+ display: flex;
618
+ align-items: center;
619
+ gap: 8px;
620
+ font-size: 12px;
621
+ }
622
+
623
+ .analytics-bar-label {
624
+ flex: 0 0 90px;
625
+ color: var(--text-secondary);
626
+ white-space: nowrap;
627
+ overflow: hidden;
628
+ text-overflow: ellipsis;
629
+ }
630
+
631
+ .analytics-bar-track {
632
+ flex: 1;
633
+ height: 6px;
634
+ background: var(--surface-hover);
635
+ border-radius: 3px;
636
+ overflow: hidden;
637
+ }
638
+
639
+ .analytics-bar-fill {
640
+ height: 100%;
641
+ border-radius: 3px;
642
+ transition: width 0.4s ease;
643
+ }
644
+
645
+ .analytics-bar-count {
646
+ flex: 0 0 28px;
647
+ text-align: right;
648
+ font-weight: 600;
649
+ color: var(--text-primary);
650
+ font-size: 12px;
651
+ }
652
+
package/ui/tabs/chat.js CHANGED
@@ -334,6 +334,17 @@ export function ChatTab() {
334
334
  };
335
335
  }, []);
336
336
 
337
+ useEffect(() => {
338
+ const onWorkspaceSwitched = () => {
339
+ selectedSessionId.value = null;
340
+ loadSessions({ type: "primary" }).catch(() => {});
341
+ };
342
+ window.addEventListener("ve:workspace-switched", onWorkspaceSwitched);
343
+ return () => {
344
+ window.removeEventListener("ve:workspace-switched", onWorkspaceSwitched);
345
+ };
346
+ }, []);
347
+
337
348
  /* ── Track mobile viewport to avoid auto-select loops ── */
338
349
  useEffect(() => {
339
350
  const mq = globalThis.matchMedia?.("(max-width: 768px)");