bosun 0.36.0 → 0.36.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 (98) hide show
  1. package/.env.example +98 -16
  2. package/README.md +27 -0
  3. package/agent-event-bus.mjs +5 -5
  4. package/agent-pool.mjs +129 -12
  5. package/agent-prompts.mjs +7 -1
  6. package/agent-sdk.mjs +13 -2
  7. package/agent-supervisor.mjs +2 -2
  8. package/agent-work-report.mjs +1 -1
  9. package/anomaly-detector.mjs +6 -6
  10. package/autofix.mjs +15 -15
  11. package/bosun-skills.mjs +4 -4
  12. package/bosun.schema.json +160 -4
  13. package/claude-shell.mjs +11 -11
  14. package/cli.mjs +21 -21
  15. package/codex-config.mjs +19 -19
  16. package/codex-shell.mjs +180 -29
  17. package/config-doctor.mjs +27 -2
  18. package/config.mjs +60 -7
  19. package/copilot-shell.mjs +4 -4
  20. package/error-detector.mjs +1 -1
  21. package/fleet-coordinator.mjs +2 -2
  22. package/gemini-shell.mjs +692 -0
  23. package/github-oauth-portal.mjs +1 -1
  24. package/github-reconciler.mjs +2 -2
  25. package/kanban-adapter.mjs +741 -168
  26. package/merge-strategy.mjs +25 -25
  27. package/monitor.mjs +123 -105
  28. package/opencode-shell.mjs +22 -22
  29. package/package.json +7 -1
  30. package/postinstall.mjs +22 -22
  31. package/pr-cleanup-daemon.mjs +6 -6
  32. package/prepublish-check.mjs +4 -4
  33. package/presence.mjs +2 -2
  34. package/primary-agent.mjs +85 -7
  35. package/publish.mjs +1 -1
  36. package/review-agent.mjs +1 -1
  37. package/session-tracker.mjs +11 -0
  38. package/setup-web-server.mjs +429 -21
  39. package/setup.mjs +367 -12
  40. package/shared-knowledge.mjs +1 -1
  41. package/startup-service.mjs +9 -9
  42. package/stream-resilience.mjs +58 -4
  43. package/sync-engine.mjs +2 -2
  44. package/task-assessment.mjs +9 -9
  45. package/task-cli.mjs +1 -1
  46. package/task-complexity.mjs +71 -2
  47. package/task-context.mjs +1 -2
  48. package/task-executor.mjs +104 -41
  49. package/telegram-bot.mjs +825 -494
  50. package/telegram-sentinel.mjs +28 -28
  51. package/ui/app.js +256 -23
  52. package/ui/app.monolith.js +1 -1
  53. package/ui/components/agent-selector.js +4 -3
  54. package/ui/components/chat-view.js +101 -28
  55. package/ui/components/diff-viewer.js +3 -3
  56. package/ui/components/kanban-board.js +3 -3
  57. package/ui/components/session-list.js +255 -35
  58. package/ui/components/workspace-switcher.js +3 -3
  59. package/ui/demo.html +209 -194
  60. package/ui/index.html +3 -3
  61. package/ui/modules/icon-utils.js +206 -142
  62. package/ui/modules/icons.js +2 -27
  63. package/ui/modules/settings-schema.js +29 -5
  64. package/ui/modules/streaming.js +30 -2
  65. package/ui/modules/vision-stream.js +275 -0
  66. package/ui/modules/voice-client.js +102 -9
  67. package/ui/modules/voice-fallback.js +62 -6
  68. package/ui/modules/voice-overlay.js +594 -59
  69. package/ui/modules/voice.js +31 -38
  70. package/ui/setup.html +284 -34
  71. package/ui/styles/components.css +47 -0
  72. package/ui/styles/sessions.css +75 -0
  73. package/ui/tabs/agents.js +73 -43
  74. package/ui/tabs/chat.js +37 -40
  75. package/ui/tabs/control.js +2 -2
  76. package/ui/tabs/dashboard.js +1 -1
  77. package/ui/tabs/infra.js +10 -10
  78. package/ui/tabs/library.js +8 -8
  79. package/ui/tabs/logs.js +10 -10
  80. package/ui/tabs/settings.js +20 -20
  81. package/ui/tabs/tasks.js +76 -47
  82. package/ui-server.mjs +1761 -124
  83. package/update-check.mjs +13 -13
  84. package/ve-kanban.mjs +1 -1
  85. package/whatsapp-channel.mjs +5 -5
  86. package/workflow-engine.mjs +20 -1
  87. package/workflow-nodes.mjs +904 -4
  88. package/workflow-templates/agents.mjs +321 -7
  89. package/workflow-templates/ci-cd.mjs +6 -6
  90. package/workflow-templates/github.mjs +156 -84
  91. package/workflow-templates/planning.mjs +8 -8
  92. package/workflow-templates/reliability.mjs +8 -8
  93. package/workflow-templates/security.mjs +3 -3
  94. package/workflow-templates.mjs +15 -9
  95. package/workspace-manager.mjs +85 -1
  96. package/workspace-monitor.mjs +2 -2
  97. package/workspace-registry.mjs +2 -2
  98. package/worktree-manager.mjs +1 -1
@@ -9,9 +9,10 @@
9
9
  */
10
10
 
11
11
  import { h } from "preact";
12
- import { useState, useEffect, useCallback } from "preact/hooks";
12
+ import { useState, useEffect, useCallback, useRef } from "preact/hooks";
13
13
  import htm from "htm";
14
14
  import { haptic } from "./telegram.js";
15
+ import { apiFetch } from "./api.js";
15
16
  import {
16
17
  voiceState, voiceTranscript, voiceResponse, voiceError,
17
18
  voiceToolCalls, voiceDuration,
@@ -22,6 +23,14 @@ import {
22
23
  fallbackError,
23
24
  startFallbackSession, stopFallbackSession, interruptFallback,
24
25
  } from "./voice-fallback.js";
26
+ import {
27
+ visionShareState,
28
+ visionShareSource,
29
+ visionShareError,
30
+ visionLastSummary,
31
+ toggleVisionShare,
32
+ stopVisionShare,
33
+ } from "./vision-stream.js";
25
34
  import { AudioVisualizer } from "./audio-visualizer.js";
26
35
  import { resolveIcon } from "./icon-utils.js";
27
36
 
@@ -43,13 +52,14 @@ function injectOverlayStyles() {
43
52
  background: rgba(0, 0, 0, 0.95);
44
53
  display: flex;
45
54
  flex-direction: column;
46
- align-items: center;
47
- justify-content: center;
55
+ align-items: stretch;
56
+ justify-content: flex-start;
48
57
  color: #fff;
49
58
  font-family: var(--tg-theme-font, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
50
59
  animation: voiceOverlayFadeIn 0.3s ease;
51
60
  backdrop-filter: blur(20px);
52
61
  -webkit-backdrop-filter: blur(20px);
62
+ overflow: hidden;
53
63
  }
54
64
  @keyframes voiceOverlayFadeIn {
55
65
  from { opacity: 0; }
@@ -66,6 +76,32 @@ function injectOverlayStyles() {
66
76
  padding: 16px 20px;
67
77
  z-index: 2;
68
78
  }
79
+ .voice-overlay-header-actions {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: 8px;
83
+ }
84
+ .voice-overlay-chat-toggle {
85
+ height: 32px;
86
+ border-radius: 999px;
87
+ border: 1px solid rgba(255,255,255,0.24);
88
+ background: rgba(255,255,255,0.08);
89
+ color: rgba(255,255,255,0.95);
90
+ font-size: 12px;
91
+ cursor: pointer;
92
+ padding: 0 12px;
93
+ }
94
+ .voice-overlay-chat-toggle:disabled {
95
+ opacity: 0.45;
96
+ cursor: not-allowed;
97
+ }
98
+ .voice-overlay-call-pill {
99
+ font-size: 11px;
100
+ border-radius: 999px;
101
+ border: 1px solid rgba(255,255,255,0.2);
102
+ padding: 3px 9px;
103
+ color: rgba(255,255,255,0.86);
104
+ }
69
105
  .voice-overlay-close {
70
106
  width: 40px;
71
107
  height: 40px;
@@ -89,6 +125,15 @@ function injectOverlayStyles() {
89
125
  color: rgba(255,255,255,0.6);
90
126
  text-transform: capitalize;
91
127
  }
128
+ .voice-overlay-bound {
129
+ font-size: 11px;
130
+ color: rgba(255,255,255,0.45);
131
+ margin-top: 2px;
132
+ max-width: 56vw;
133
+ white-space: nowrap;
134
+ overflow: hidden;
135
+ text-overflow: ellipsis;
136
+ }
92
137
  .voice-overlay-duration {
93
138
  font-size: 12px;
94
139
  color: rgba(255,255,255,0.4);
@@ -101,6 +146,132 @@ function injectOverlayStyles() {
101
146
  gap: 32px;
102
147
  z-index: 1;
103
148
  }
149
+ .voice-overlay-main {
150
+ flex: 1;
151
+ min-height: 0;
152
+ display: flex;
153
+ align-items: stretch;
154
+ gap: 14px;
155
+ padding: 76px 16px 18px;
156
+ }
157
+ .voice-overlay-stage {
158
+ flex: 1;
159
+ min-width: 0;
160
+ min-height: 0;
161
+ display: flex;
162
+ flex-direction: column;
163
+ align-items: center;
164
+ justify-content: center;
165
+ gap: 18px;
166
+ }
167
+ .voice-overlay-chat {
168
+ width: min(420px, 42vw);
169
+ min-width: 300px;
170
+ max-width: 460px;
171
+ border-radius: 16px;
172
+ border: 1px solid rgba(255,255,255,0.14);
173
+ background: rgba(10, 10, 12, 0.78);
174
+ display: flex;
175
+ flex-direction: column;
176
+ overflow: hidden;
177
+ }
178
+ .voice-overlay-chat-head {
179
+ display: flex;
180
+ align-items: center;
181
+ justify-content: space-between;
182
+ gap: 8px;
183
+ padding: 10px 12px;
184
+ border-bottom: 1px solid rgba(255,255,255,0.1);
185
+ }
186
+ .voice-overlay-chat-title {
187
+ font-size: 12px;
188
+ font-weight: 600;
189
+ letter-spacing: 0.02em;
190
+ }
191
+ .voice-overlay-chat-status {
192
+ font-size: 11px;
193
+ color: rgba(255,255,255,0.6);
194
+ }
195
+ .voice-overlay-chat-body {
196
+ flex: 1;
197
+ min-height: 0;
198
+ overflow-y: auto;
199
+ padding: 10px;
200
+ display: flex;
201
+ flex-direction: column;
202
+ gap: 8px;
203
+ }
204
+ .voice-overlay-chat-empty {
205
+ color: rgba(255,255,255,0.6);
206
+ text-align: center;
207
+ font-size: 12px;
208
+ padding: 16px 8px;
209
+ }
210
+ .voice-overlay-chat-msg {
211
+ padding: 8px 10px;
212
+ border-radius: 10px;
213
+ background: rgba(255,255,255,0.06);
214
+ border: 1px solid rgba(255,255,255,0.08);
215
+ }
216
+ .voice-overlay-chat-msg.user {
217
+ align-self: flex-end;
218
+ background: rgba(59,130,246,0.24);
219
+ border-color: rgba(59,130,246,0.38);
220
+ }
221
+ .voice-overlay-chat-msg.assistant {
222
+ align-self: flex-start;
223
+ background: rgba(34,197,94,0.2);
224
+ border-color: rgba(34,197,94,0.34);
225
+ }
226
+ .voice-overlay-chat-msg.system {
227
+ align-self: stretch;
228
+ }
229
+ .voice-overlay-chat-meta {
230
+ display: flex;
231
+ justify-content: space-between;
232
+ align-items: center;
233
+ gap: 8px;
234
+ font-size: 10px;
235
+ color: rgba(255,255,255,0.56);
236
+ margin-bottom: 4px;
237
+ text-transform: uppercase;
238
+ letter-spacing: 0.03em;
239
+ }
240
+ .voice-overlay-chat-content {
241
+ white-space: pre-wrap;
242
+ word-break: break-word;
243
+ font-size: 13px;
244
+ line-height: 1.35;
245
+ }
246
+ .voice-overlay-chat-input-wrap {
247
+ border-top: 1px solid rgba(255,255,255,0.1);
248
+ padding: 10px;
249
+ display: flex;
250
+ gap: 8px;
251
+ }
252
+ .voice-overlay-chat-input {
253
+ flex: 1;
254
+ min-width: 0;
255
+ border: 1px solid rgba(255,255,255,0.18);
256
+ border-radius: 8px;
257
+ background: rgba(255,255,255,0.06);
258
+ color: #fff;
259
+ padding: 8px 10px;
260
+ font-size: 13px;
261
+ }
262
+ .voice-overlay-chat-send {
263
+ border: none;
264
+ border-radius: 8px;
265
+ background: #2563eb;
266
+ color: #fff;
267
+ font-size: 12px;
268
+ padding: 0 12px;
269
+ cursor: pointer;
270
+ }
271
+ .voice-overlay-chat-send:disabled {
272
+ opacity: 0.48;
273
+ cursor: not-allowed;
274
+ }
104
275
  .voice-orb-container {
105
276
  width: 200px;
106
277
  height: 200px;
@@ -153,14 +324,10 @@ function injectOverlayStyles() {
153
324
  color: #f87171;
154
325
  }
155
326
  .voice-overlay-footer {
156
- position: absolute;
157
- bottom: 0;
158
- left: 0;
159
- right: 0;
160
327
  display: flex;
161
328
  justify-content: center;
162
- padding: 24px;
163
- z-index: 2;
329
+ padding: 0;
330
+ z-index: 1;
164
331
  }
165
332
  .voice-end-btn {
166
333
  width: 64px;
@@ -181,6 +348,38 @@ function injectOverlayStyles() {
181
348
  transform: scale(1.05);
182
349
  box-shadow: 0 6px 28px rgba(239, 68, 68, 0.5);
183
350
  }
351
+ .voice-overlay-vision-controls {
352
+ display: flex;
353
+ gap: 8px;
354
+ align-items: center;
355
+ }
356
+ .voice-vision-btn {
357
+ min-width: 84px;
358
+ height: 32px;
359
+ border-radius: 999px;
360
+ border: 1px solid rgba(255,255,255,0.25);
361
+ background: rgba(255,255,255,0.08);
362
+ color: rgba(255,255,255,0.9);
363
+ font-size: 12px;
364
+ cursor: pointer;
365
+ padding: 0 12px;
366
+ }
367
+ .voice-vision-btn.active {
368
+ border-color: rgba(74, 222, 128, 0.8);
369
+ background: rgba(34, 197, 94, 0.2);
370
+ color: #bbf7d0;
371
+ }
372
+ .voice-vision-btn:disabled {
373
+ opacity: 0.45;
374
+ cursor: not-allowed;
375
+ }
376
+ .voice-vision-status {
377
+ margin-top: 8px;
378
+ max-width: 560px;
379
+ text-align: center;
380
+ font-size: 12px;
381
+ color: rgba(255,255,255,0.58);
382
+ }
184
383
  .voice-error-msg {
185
384
  color: #f87171;
186
385
  font-size: 14px;
@@ -203,6 +402,26 @@ function injectOverlayStyles() {
203
402
  @keyframes voiceDotPulse {
204
403
  0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
205
404
  40% { opacity: 1; transform: scale(1.2); }
405
+ }
406
+ @media (max-width: 980px) {
407
+ .voice-overlay-main {
408
+ padding: 74px 12px 14px;
409
+ flex-direction: column;
410
+ gap: 12px;
411
+ }
412
+ .voice-overlay-chat {
413
+ width: 100%;
414
+ min-width: 0;
415
+ max-width: none;
416
+ max-height: 44vh;
417
+ }
418
+ .voice-overlay-center {
419
+ gap: 18px;
420
+ }
421
+ .voice-orb-container {
422
+ width: 162px;
423
+ height: 162px;
424
+ }
206
425
  }
207
426
  `;
208
427
  document.head.appendChild(style);
@@ -219,10 +438,38 @@ function formatDuration(seconds) {
219
438
  // ── Voice Overlay Component ─────────────────────────────────────────────────
220
439
 
221
440
  /**
222
- * @param {{ visible: boolean, onClose: () => void, tier: number, sessionId?: string }} props
441
+ * @param {{
442
+ * visible: boolean,
443
+ * onClose: () => void,
444
+ * tier: number,
445
+ * sessionId?: string,
446
+ * executor?: string,
447
+ * mode?: string,
448
+ * model?: string,
449
+ * callType?: "voice" | "video",
450
+ * initialVisionSource?: "camera" | "screen" | null
451
+ * }} props
223
452
  */
224
- export function VoiceOverlay({ visible, onClose, tier = 1, sessionId }) {
453
+ export function VoiceOverlay({
454
+ visible,
455
+ onClose,
456
+ tier = 1,
457
+ sessionId,
458
+ executor,
459
+ mode,
460
+ model,
461
+ callType = "voice",
462
+ initialVisionSource = null,
463
+ }) {
225
464
  const [started, setStarted] = useState(false);
465
+ const [chatOpen, setChatOpen] = useState(true);
466
+ const [meetingMessages, setMeetingMessages] = useState([]);
467
+ const [meetingChatInput, setMeetingChatInput] = useState("");
468
+ const [meetingChatSending, setMeetingChatSending] = useState(false);
469
+ const [meetingChatLoading, setMeetingChatLoading] = useState(false);
470
+ const [meetingChatError, setMeetingChatError] = useState(null);
471
+ const autoVisionAppliedRef = useRef(false);
472
+ const meetingScrollRef = useRef(null);
226
473
 
227
474
  useEffect(() => { injectOverlayStyles(); }, []);
228
475
 
@@ -233,20 +480,126 @@ export function VoiceOverlay({ visible, onClose, tier = 1, sessionId }) {
233
480
  const error = tier === 1 ? voiceError.value : fallbackError.value;
234
481
  const toolCalls = tier === 1 ? voiceToolCalls.value : [];
235
482
  const duration = tier === 1 ? voiceDuration.value : 0;
483
+ const visionState = visionShareState.value;
484
+ const visionSource = visionShareSource.value;
485
+ const visionErr = visionShareError.value;
486
+ const latestVisionSummary = visionLastSummary.value;
487
+ const canShareVision = Boolean(sessionId);
488
+ const normalizedCallType =
489
+ String(callType || "").trim().toLowerCase() === "video"
490
+ ? "video"
491
+ : "voice";
492
+ const normalizedInitialVisionSource = (() => {
493
+ const source = String(initialVisionSource || "").trim().toLowerCase();
494
+ if (source === "camera" || source === "screen") return source;
495
+ return normalizedCallType === "video" ? "camera" : null;
496
+ })();
236
497
 
237
498
  // Start session on mount
238
499
  useEffect(() => {
239
500
  if (!visible || started) return;
240
501
  setStarted(true);
241
502
  if (tier === 1) {
242
- startVoiceSession();
503
+ startVoiceSession({ sessionId, executor, mode, model });
243
504
  } else if (sessionId) {
244
- startFallbackSession(sessionId);
505
+ startFallbackSession(sessionId, { executor, mode, model });
245
506
  }
246
- }, [visible, started, tier, sessionId]);
507
+ }, [visible, started, tier, sessionId, executor, mode, model]);
508
+
509
+ useEffect(() => {
510
+ if (visible) return;
511
+ stopVisionShare().catch(() => {});
512
+ }, [visible]);
513
+
514
+ useEffect(() => {
515
+ if (visible) return;
516
+ autoVisionAppliedRef.current = false;
517
+ }, [visible]);
518
+
519
+ const loadMeetingMessages = useCallback(async () => {
520
+ const activeSessionId = String(sessionId || "").trim();
521
+ if (!activeSessionId) {
522
+ setMeetingMessages([]);
523
+ setMeetingChatError(null);
524
+ return;
525
+ }
526
+ const safeSessionId = encodeURIComponent(activeSessionId);
527
+ const response = await apiFetch(`/api/sessions/${safeSessionId}?limit=80`, {
528
+ _silent: true,
529
+ _trackLoading: false,
530
+ });
531
+ const nextMessages = Array.isArray(response?.session?.messages)
532
+ ? response.session.messages
533
+ : [];
534
+ setMeetingMessages(nextMessages);
535
+ setMeetingChatError(null);
536
+ }, [sessionId]);
537
+
538
+ useEffect(() => {
539
+ if (!visible || !chatOpen || !sessionId) return;
540
+ let cancelled = false;
541
+
542
+ const refresh = async (isInitial = false) => {
543
+ if (isInitial) setMeetingChatLoading(true);
544
+ try {
545
+ await loadMeetingMessages();
546
+ } catch (err) {
547
+ if (!cancelled) {
548
+ setMeetingChatError(
549
+ String(err?.message || "Could not refresh meeting chat"),
550
+ );
551
+ }
552
+ } finally {
553
+ if (isInitial && !cancelled) setMeetingChatLoading(false);
554
+ }
555
+ };
556
+
557
+ refresh(true).catch(() => {});
558
+ const timer = setInterval(() => {
559
+ refresh(false).catch(() => {});
560
+ }, 1600);
561
+ return () => {
562
+ cancelled = true;
563
+ clearInterval(timer);
564
+ };
565
+ }, [visible, chatOpen, sessionId, loadMeetingMessages]);
566
+
567
+ useEffect(() => {
568
+ if (!chatOpen) return;
569
+ const el = meetingScrollRef.current;
570
+ if (!el) return;
571
+ el.scrollTop = el.scrollHeight;
572
+ }, [chatOpen, meetingMessages.length]);
573
+
574
+ useEffect(() => {
575
+ if (!visible || !started || !sessionId) return;
576
+ if (!normalizedInitialVisionSource) return;
577
+ if (autoVisionAppliedRef.current) return;
578
+ autoVisionAppliedRef.current = true;
579
+ toggleVisionShare(normalizedInitialVisionSource, {
580
+ sessionId,
581
+ executor,
582
+ mode,
583
+ model,
584
+ intervalMs: 1000,
585
+ maxWidth: normalizedInitialVisionSource === "screen" ? 1280 : 960,
586
+ jpegQuality: normalizedInitialVisionSource === "screen" ? 0.65 : 0.62,
587
+ }).catch(() => {
588
+ // Keep the session running even if camera/screen permissions fail.
589
+ });
590
+ }, [
591
+ visible,
592
+ started,
593
+ sessionId,
594
+ normalizedInitialVisionSource,
595
+ executor,
596
+ mode,
597
+ model,
598
+ ]);
247
599
 
248
600
  const handleClose = useCallback(() => {
249
601
  haptic("medium");
602
+ stopVisionShare().catch(() => {});
250
603
  if (tier === 1) {
251
604
  stopVoiceSession();
252
605
  } else {
@@ -265,12 +618,78 @@ export function VoiceOverlay({ visible, onClose, tier = 1, sessionId }) {
265
618
  }
266
619
  }, [tier]);
267
620
 
621
+ const handleToggleScreenShare = useCallback(() => {
622
+ haptic("light");
623
+ toggleVisionShare("screen", {
624
+ sessionId,
625
+ executor,
626
+ mode,
627
+ model,
628
+ intervalMs: 1000,
629
+ maxWidth: 1280,
630
+ jpegQuality: 0.65,
631
+ }).catch(() => {});
632
+ }, [sessionId, executor, mode, model]);
633
+
634
+ const handleToggleCameraShare = useCallback(() => {
635
+ haptic("light");
636
+ toggleVisionShare("camera", {
637
+ sessionId,
638
+ executor,
639
+ mode,
640
+ model,
641
+ intervalMs: 1000,
642
+ maxWidth: 960,
643
+ jpegQuality: 0.62,
644
+ }).catch(() => {});
645
+ }, [sessionId, executor, mode, model]);
646
+
647
+ const handleSendMeetingChat = useCallback(async () => {
648
+ const activeSessionId = String(sessionId || "").trim();
649
+ const content = String(meetingChatInput || "").trim();
650
+ if (!activeSessionId || !content || meetingChatSending) return;
651
+ setMeetingChatSending(true);
652
+ const safeSessionId = encodeURIComponent(activeSessionId);
653
+ try {
654
+ await apiFetch(`/api/sessions/${safeSessionId}/message`, {
655
+ method: "POST",
656
+ body: JSON.stringify({ content }),
657
+ });
658
+ setMeetingChatInput("");
659
+ await loadMeetingMessages();
660
+ } catch (err) {
661
+ setMeetingChatError(String(err?.message || "Could not send chat message"));
662
+ } finally {
663
+ setMeetingChatSending(false);
664
+ }
665
+ }, [
666
+ meetingChatInput,
667
+ meetingChatSending,
668
+ sessionId,
669
+ loadMeetingMessages,
670
+ ]);
671
+
268
672
  if (!visible) return null;
269
673
 
270
674
  const statusLabel = state === "connected" ? "ready" : state;
675
+ const boundLabel = [
676
+ sessionId ? `session ${sessionId}` : null,
677
+ executor ? `agent ${executor}` : null,
678
+ mode ? `mode ${mode}` : null,
679
+ model ? `model ${model}` : null,
680
+ ]
681
+ .filter(Boolean)
682
+ .join(" · ");
683
+ const chatStatusLabel = meetingChatLoading
684
+ ? "syncing"
685
+ : meetingChatSending
686
+ ? "sending"
687
+ : meetingChatError
688
+ ? "error"
689
+ : "live";
271
690
 
272
691
  return html`
273
- <div class="voice-overlay" onClick=${state === "speaking" ? handleInterrupt : undefined}>
692
+ <div class="voice-overlay">
274
693
  <!-- Header -->
275
694
  <div class="voice-overlay-header">
276
695
  <button class="voice-overlay-close" onClick=${handleClose} title="End voice session">
@@ -278,63 +697,179 @@ export function VoiceOverlay({ visible, onClose, tier = 1, sessionId }) {
278
697
  </button>
279
698
  <div>
280
699
  <div class="voice-overlay-status">${statusLabel}</div>
700
+ ${boundLabel && html`<div class="voice-overlay-bound">${boundLabel}</div>`}
281
701
  ${duration > 0 && html`
282
702
  <div class="voice-overlay-duration">${formatDuration(duration)}</div>
283
703
  `}
284
704
  </div>
285
- <div style="width: 40px" />
705
+ <div class="voice-overlay-header-actions">
706
+ <span class="voice-overlay-call-pill">
707
+ ${normalizedCallType === "video" ? "video call" : "voice call"}
708
+ </span>
709
+ <button
710
+ class="voice-overlay-chat-toggle"
711
+ onClick=${() => setChatOpen((prev) => !prev)}
712
+ disabled=${!sessionId}
713
+ title=${sessionId ? "Toggle meeting chat" : "Open a session-bound call first"}
714
+ >
715
+ ${chatOpen ? "Hide Chat" : "Show Chat"}
716
+ </button>
717
+ </div>
286
718
  </div>
287
719
 
288
- <!-- Center content -->
289
- <div class="voice-overlay-center">
290
- <!-- Orb visualization -->
291
- <div class="voice-orb-container">
292
- <${AudioVisualizer} state=${state} />
293
- </div>
720
+ <div class="voice-overlay-main">
721
+ <div class="voice-overlay-stage">
722
+ <!-- Center content -->
723
+ <div class="voice-overlay-center">
724
+ <!-- Orb visualization -->
725
+ <div
726
+ class="voice-orb-container"
727
+ onClick=${state === "speaking" ? handleInterrupt : undefined}
728
+ >
729
+ <${AudioVisualizer} state=${state} />
730
+ </div>
294
731
 
295
- ${state === "connecting" || state === "reconnecting"
296
- ? html`
297
- <div class="voice-connecting-dots">
298
- <span /><span /><span />
299
- </div>
300
- <div class="voice-overlay-status" style="font-size: 16px">
301
- ${state === "reconnecting" ? "Reconnecting..." : "Connecting..."}
302
- </div>
303
- `
304
- : null}
732
+ ${state === "connecting" || state === "reconnecting"
733
+ ? html`
734
+ <div class="voice-connecting-dots">
735
+ <span /><span /><span />
736
+ </div>
737
+ <div class="voice-overlay-status" style="font-size: 16px">
738
+ ${state === "reconnecting" ? "Reconnecting..." : "Connecting..."}
739
+ </div>
740
+ `
741
+ : null}
305
742
 
306
- ${error && html`
307
- <div class="voice-error-msg">${error}</div>
308
- `}
743
+ ${error && html`
744
+ <div class="voice-error-msg">${error}</div>
745
+ `}
309
746
 
310
- <!-- Transcript area -->
311
- <div class="voice-transcript-area">
312
- ${transcript && html`
313
- <div class="voice-transcript-user">"${transcript}"</div>
314
- `}
315
- ${response && html`
316
- <div class="voice-transcript-assistant">${response}</div>
317
- `}
318
- </div>
747
+ <!-- Transcript area -->
748
+ <div class="voice-transcript-area">
749
+ ${transcript && html`
750
+ <div class="voice-transcript-user">"${transcript}"</div>
751
+ `}
752
+ ${response && html`
753
+ <div class="voice-transcript-assistant">${response}</div>
754
+ `}
755
+ </div>
319
756
 
320
- <!-- Tool call cards -->
321
- ${toolCalls.length > 0 && html`
322
- <div class="voice-tool-cards">
323
- ${toolCalls.slice(-5).map(tc => html`
324
- <div class="voice-tool-card ${tc.status}" key=${tc.callId}>
325
- <span>${tc.status === "running" ? resolveIcon("loading") : tc.status === "complete" ? resolveIcon("check") : resolveIcon("alert")}</span>
326
- <span>${tc.name}</span>
757
+ <div class="voice-overlay-vision-controls">
758
+ <button
759
+ class="voice-vision-btn ${visionState === "streaming" && visionSource === "screen" ? "active" : ""}"
760
+ onClick=${handleToggleScreenShare}
761
+ disabled=${!canShareVision}
762
+ title=${canShareVision ? "Share your screen with the active agent call" : "Open a session-bound call first"}
763
+ >
764
+ ${visionState === "streaming" && visionSource === "screen" ? "Stop Screen" : "Share Screen"}
765
+ </button>
766
+ <button
767
+ class="voice-vision-btn ${visionState === "streaming" && visionSource === "camera" ? "active" : ""}"
768
+ onClick=${handleToggleCameraShare}
769
+ disabled=${!canShareVision}
770
+ title=${canShareVision ? "Share your camera with the active agent call" : "Open a session-bound call first"}
771
+ >
772
+ ${visionState === "streaming" && visionSource === "camera" ? "Stop Camera" : "Share Camera"}
773
+ </button>
774
+ </div>
775
+
776
+ ${(visionErr || latestVisionSummary) && html`
777
+ <div class="voice-vision-status">
778
+ ${visionErr || latestVisionSummary}
779
+ </div>
780
+ `}
781
+
782
+ <!-- Tool call cards -->
783
+ ${toolCalls.length > 0 && html`
784
+ <div class="voice-tool-cards">
785
+ ${toolCalls.slice(-5).map(tc => html`
786
+ <div class="voice-tool-card ${tc.status}" key=${tc.callId}>
787
+ <span>${tc.status === "running" ? resolveIcon("loading") : tc.status === "complete" ? resolveIcon("check") : resolveIcon("alert")}</span>
788
+ <span>${tc.name}</span>
789
+ </div>
790
+ `)}
327
791
  </div>
328
- `)}
792
+ `}
329
793
  </div>
330
- `}
331
- </div>
332
794
 
333
- <!-- Footer -->
334
- <div class="voice-overlay-footer">
335
- <button class="voice-end-btn" onClick=${handleClose} title="End call">
336
- ${resolveIcon("close")}
337
- </button>
795
+ <!-- Footer -->
796
+ <div class="voice-overlay-footer">
797
+ <button class="voice-end-btn" onClick=${handleClose} title="End call">
798
+ ${resolveIcon("close")}
799
+ </button>
800
+ </div>
801
+ </div>
802
+
803
+ ${chatOpen && sessionId && html`
804
+ <aside class="voice-overlay-chat">
805
+ <div class="voice-overlay-chat-head">
806
+ <div class="voice-overlay-chat-title">Meeting Chat + Transcript</div>
807
+ <div class="voice-overlay-chat-status">${chatStatusLabel}</div>
808
+ </div>
809
+ <div class="voice-overlay-chat-body" ref=${meetingScrollRef}>
810
+ ${meetingMessages.length === 0 && !meetingChatLoading
811
+ ? html`<div class="voice-overlay-chat-empty">Conversation will appear here once the call starts.</div>`
812
+ : null}
813
+ ${meetingMessages.map((msg, idx) => {
814
+ const roleRaw = String(
815
+ msg?.role ||
816
+ (msg?.type === "tool_call" || msg?.type === "tool_result"
817
+ ? "system"
818
+ : "assistant"),
819
+ )
820
+ .trim()
821
+ .toLowerCase();
822
+ const role =
823
+ roleRaw === "user" || roleRaw === "assistant" ? roleRaw : "system";
824
+ const timeRaw = String(msg?.timestamp || "").trim();
825
+ const date = timeRaw ? new Date(timeRaw) : null;
826
+ const timeLabel =
827
+ date && Number.isFinite(date.getTime())
828
+ ? date.toLocaleTimeString([], {
829
+ hour: "2-digit",
830
+ minute: "2-digit",
831
+ })
832
+ : "";
833
+ const text =
834
+ typeof msg?.content === "string"
835
+ ? msg.content
836
+ : msg?.content == null
837
+ ? ""
838
+ : JSON.stringify(msg.content);
839
+ return html`
840
+ <div class="voice-overlay-chat-msg ${role}" key=${msg?.id || `${role}-${idx}`}>
841
+ <div class="voice-overlay-chat-meta">
842
+ <span>${role}</span>
843
+ <span>${timeLabel}</span>
844
+ </div>
845
+ <div class="voice-overlay-chat-content">${text}</div>
846
+ </div>
847
+ `;
848
+ })}
849
+ </div>
850
+ <div class="voice-overlay-chat-input-wrap">
851
+ <input
852
+ class="voice-overlay-chat-input"
853
+ placeholder="Message the agent during the call…"
854
+ value=${meetingChatInput}
855
+ onInput=${(e) => setMeetingChatInput(e.target.value)}
856
+ onKeyDown=${(e) => {
857
+ if (e.key === "Enter" && !e.shiftKey) {
858
+ e.preventDefault();
859
+ handleSendMeetingChat().catch(() => {});
860
+ }
861
+ }}
862
+ />
863
+ <button
864
+ class="voice-overlay-chat-send"
865
+ onClick=${() => handleSendMeetingChat().catch(() => {})}
866
+ disabled=${!meetingChatInput.trim() || meetingChatSending}
867
+ >
868
+ Send
869
+ </button>
870
+ </div>
871
+ </aside>
872
+ `}
338
873
  </div>
339
874
  </div>
340
875
  `;