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
@@ -710,6 +710,53 @@ select.input {
710
710
  line-height: 1.5;
711
711
  }
712
712
 
713
+ .repo-select-group {
714
+ display: flex;
715
+ flex-direction: column;
716
+ gap: 6px;
717
+ }
718
+
719
+ .repo-auto-label {
720
+ font-size: 13px;
721
+ color: var(--text-secondary, #aaa);
722
+ padding: 4px 2px;
723
+ }
724
+
725
+ .repo-auto-label strong {
726
+ color: var(--text-primary, #fff);
727
+ }
728
+
729
+ .repo-checkboxes {
730
+ display: flex;
731
+ flex-direction: column;
732
+ gap: 4px;
733
+ }
734
+
735
+ .repo-checkboxes-label {
736
+ font-size: 12px;
737
+ text-transform: uppercase;
738
+ letter-spacing: 0.05em;
739
+ color: var(--text-hint, #888);
740
+ margin-bottom: 2px;
741
+ }
742
+
743
+ .repo-checkbox-item {
744
+ display: flex;
745
+ align-items: center;
746
+ gap: 8px;
747
+ font-size: 13px;
748
+ color: var(--text-primary, #fff);
749
+ cursor: pointer;
750
+ padding: 3px 2px;
751
+ }
752
+
753
+ .repo-checkbox-item input[type="checkbox"] {
754
+ accent-color: var(--accent, #7c6af7);
755
+ width: 15px;
756
+ height: 15px;
757
+ flex-shrink: 0;
758
+ }
759
+
713
760
  .create-task-advanced-toggle {
714
761
  font-size: 12px;
715
762
  letter-spacing: 0.02em;
@@ -1218,6 +1218,81 @@
1218
1218
  background: rgba(255, 255, 255, 0.2);
1219
1219
  }
1220
1220
 
1221
+ /* ─── Thinking Group — collapsed view of consecutive trace events ─── */
1222
+ .thinking-group {
1223
+ align-self: stretch;
1224
+ border: 1px solid var(--border);
1225
+ border-radius: 12px;
1226
+ background: rgba(255, 255, 255, 0.02);
1227
+ margin: 2px 0 4px;
1228
+ overflow: hidden;
1229
+ }
1230
+
1231
+ .thinking-group.has-errors {
1232
+ border-color: rgba(239, 68, 68, 0.35);
1233
+ }
1234
+
1235
+ .thinking-group-head {
1236
+ display: flex;
1237
+ align-items: center;
1238
+ gap: 8px;
1239
+ padding: 7px 10px;
1240
+ width: 100%;
1241
+ border: 0;
1242
+ background: transparent;
1243
+ color: inherit;
1244
+ cursor: pointer;
1245
+ text-align: left;
1246
+ }
1247
+
1248
+ .thinking-group-head:hover {
1249
+ background: rgba(255, 255, 255, 0.03);
1250
+ }
1251
+
1252
+ .thinking-group-badge {
1253
+ display: inline-flex;
1254
+ align-items: center;
1255
+ gap: 4px;
1256
+ padding: 2px 7px;
1257
+ border-radius: 999px;
1258
+ border: 1px solid rgba(245, 158, 11, 0.35);
1259
+ background: rgba(245, 158, 11, 0.12);
1260
+ font-size: 9px;
1261
+ font-weight: 700;
1262
+ letter-spacing: 0.07em;
1263
+ text-transform: uppercase;
1264
+ color: #fde68a;
1265
+ flex-shrink: 0;
1266
+ }
1267
+
1268
+ .thinking-group-badge svg {
1269
+ width: 10px;
1270
+ height: 10px;
1271
+ opacity: 0.85;
1272
+ }
1273
+
1274
+ .thinking-group-label {
1275
+ flex: 1;
1276
+ min-width: 0;
1277
+ font-size: 11px;
1278
+ font-weight: 400;
1279
+ color: var(--text-secondary);
1280
+ }
1281
+
1282
+ .thinking-group-chevron {
1283
+ flex-shrink: 0;
1284
+ font-size: 13px;
1285
+ color: var(--text-hint);
1286
+ }
1287
+
1288
+ .thinking-group-body {
1289
+ border-top: 1px solid var(--border);
1290
+ padding: 6px;
1291
+ display: flex;
1292
+ flex-direction: column;
1293
+ gap: 2px;
1294
+ }
1295
+
1221
1296
  /* Subtle entrance for bubbles — only the last few get the animation
1222
1297
  (content-visibility: auto on older bubbles skips them for free) */
1223
1298
  .chat-bubble:last-child,
package/ui/tabs/agents.js CHANGED
@@ -87,6 +87,28 @@ function formatTaskOptionLabel(task) {
87
87
  return `#${numberToken} ${task?.title || "(untitled task)"}`;
88
88
  }
89
89
 
90
+ function normalizeDispatchTaskChoices(tasks) {
91
+ if (!Array.isArray(tasks)) return [];
92
+ const deduped = [];
93
+ const seenTaskIds = new Set();
94
+ for (const task of tasks) {
95
+ if (!task || typeof task !== "object") continue;
96
+ const status = String(task?.status || "").toLowerCase();
97
+ const dispatchable =
98
+ task?.draft === true || status === "draft" || status === "todo";
99
+ if (!dispatchable) continue;
100
+ const taskId = String(task?.id ?? task?.taskId ?? "").trim();
101
+ if (!taskId || seenTaskIds.has(taskId)) continue;
102
+ seenTaskIds.add(taskId);
103
+ deduped.push({ ...task, id: taskId });
104
+ }
105
+ return deduped.sort((a, b) => taskSortScore(b) - taskSortScore(a));
106
+ }
107
+
108
+ function fleetSlotKey(index) {
109
+ return `slot-${index}`;
110
+ }
111
+
90
112
  /* ─── Workspace Viewer Modal ─── */
91
113
  function WorkspaceViewer({ agent, onClose }) {
92
114
  const [logText, setLogText] = useState("Loading…");
@@ -445,7 +467,7 @@ function WorkspaceViewer({ agent, onClose }) {
445
467
  ${sessionInfo.preview &&
446
468
  html`<div class="meta-text mt-xs">${truncate(sessionInfo.preview, 120)}</div>`}
447
469
  <button class="btn btn-ghost btn-sm mt-sm" onClick=${() => setActiveTab("stream")}>
448
- ${iconText("💬 View Stream")}
470
+ ${iconText(":chat: View Stream")}
449
471
  </button>
450
472
  </div>
451
473
  `}
@@ -565,7 +587,7 @@ function WorkspaceViewer({ agent, onClose }) {
565
587
  setStreamSnapshot({ events: [], fileAccess: null, capturedAt: null });
566
588
  }
567
589
  }}>
568
- ${streamPaused ? " Resume" : " Pause"}
590
+ ${streamPaused ? ":play: Resume" : ":pause: Pause"}
569
591
  </button>
570
592
  <button
571
593
  class="btn btn-ghost btn-sm"
@@ -618,7 +640,7 @@ function WorkspaceViewer({ agent, onClose }) {
618
640
  `}
619
641
  ${filteredEvents.length === 0 &&
620
642
  html`<div class="stream-empty">
621
- <div class="stream-empty-icon">${resolveIcon("🛰")}</div>
643
+ <div class="stream-empty-icon">${resolveIcon(":server:")}</div>
622
644
  <div class="stream-empty-text">
623
645
  ${toolEvents.length === 0 ? "No tool events yet" : "No events match filters"}
624
646
  </div>
@@ -751,7 +773,7 @@ function WorkspaceViewer({ agent, onClose }) {
751
773
  html`<div class="meta-text mt-xs">Paused at ${snapshotMeta}</div>`}
752
774
  ${filteredFiles.length === 0 &&
753
775
  html`<div class="stream-empty">
754
- <div class="stream-empty-icon">${resolveIcon("📂")}</div>
776
+ <div class="stream-empty-icon">${resolveIcon(":folder:")}</div>
755
777
  <div class="stream-empty-text">
756
778
  ${summaryFiles.length === 0 ? "No file access recorded" : "No files match filters"}
757
779
  </div>
@@ -819,7 +841,7 @@ function WorkspaceViewer({ agent, onClose }) {
819
841
  ${sessionInfo.preview &&
820
842
  html`<div class="meta-text mt-xs">${truncate(sessionInfo.preview, 140)}</div>`}
821
843
  <button class="btn btn-ghost btn-sm mt-sm" onClick=${() => setActiveTab("stream")}>
822
- ${iconText("💬 View Stream")}
844
+ ${iconText(":chat: View Stream")}
823
845
  </button>
824
846
  </div>
825
847
  `}
@@ -902,15 +924,15 @@ function WorkspaceViewer({ agent, onClose }) {
902
924
  <button
903
925
  class="session-detail-tab ${activeTab === "stream" ? "active" : ""}"
904
926
  onClick=${() => setActiveTab("stream")}
905
- >${iconText("💬 Stream")}</button>
927
+ >${iconText(":chat: Stream")}</button>
906
928
  <button
907
929
  class="session-detail-tab ${activeTab === "changes" ? "active" : ""}"
908
930
  onClick=${() => setActiveTab("changes")}
909
- >${iconText("📝 Changes")}</button>
931
+ >${iconText(":edit: Changes")}</button>
910
932
  <button
911
933
  class="session-detail-tab ${activeTab === "logs" ? "active" : ""}"
912
934
  onClick=${() => setActiveTab("logs")}
913
- >${iconText("📄 Logs")}</button>
935
+ >${iconText(":file: Logs")}</button>
914
936
  </div>
915
937
 
916
938
  <div class="workspace-body">
@@ -920,7 +942,7 @@ function WorkspaceViewer({ agent, onClose }) {
920
942
  ? html`<${ChatView} sessionId=${sessionId} readOnly=${true} />`
921
943
  : html`
922
944
  <div class="chat-view chat-empty-state">
923
- <div class="session-empty-icon">${resolveIcon("💬")}</div>
945
+ <div class="session-empty-icon">${resolveIcon(":chat:")}</div>
924
946
  <div class="session-empty-text">No session stream available</div>
925
947
  </div>
926
948
  `}
@@ -938,12 +960,12 @@ function WorkspaceViewer({ agent, onClose }) {
938
960
  onInput=${(e) => setSteerInput(e.target.value)}
939
961
  onKeyDown=${(e) => { if (e.key === "Enter") { e.preventDefault(); handleSteer(); } }}
940
962
  />
941
- <button class="btn btn-primary btn-sm" onClick=${handleSteer}>${resolveIcon("🎯")}</button>
963
+ <button class="btn btn-primary btn-sm" onClick=${handleSteer}>${resolveIcon(":target:")}</button>
942
964
  <button
943
965
  class="btn btn-danger btn-sm"
944
966
  disabled=${agent.index == null}
945
967
  onClick=${handleStop}
946
- >${iconText(" Stop")}</button>
968
+ >${iconText(":ban: Stop")}</button>
947
969
  </div>
948
970
  </div>
949
971
  </div>
@@ -958,26 +980,34 @@ function DispatchSection({ freeSlots, inputRef, className = "" }) {
958
980
  const [dispatching, setDispatching] = useState(false);
959
981
  const [taskChoices, setTaskChoices] = useState([]);
960
982
  const [tasksLoading, setTasksLoading] = useState(false);
983
+ const latestTaskRequestRef = useRef(0);
984
+ const mountedRef = useRef(true);
961
985
 
962
986
  const canDispatch = Boolean(taskId.trim() || prompt.trim());
963
987
 
988
+ useEffect(() => {
989
+ mountedRef.current = true;
990
+ return () => {
991
+ mountedRef.current = false;
992
+ };
993
+ }, []);
994
+
964
995
  const loadDispatchTasks = useCallback(() => {
996
+ const requestId = latestTaskRequestRef.current + 1;
997
+ latestTaskRequestRef.current = requestId;
965
998
  setTasksLoading(true);
966
999
  apiFetch("/api/tasks?limit=1000", { _silent: true })
967
1000
  .then((res) => {
968
- const tasks = Array.isArray(res?.data) ? res.data : [];
969
- const choices = tasks
970
- .filter((task) => {
971
- const status = String(task?.status || "").toLowerCase();
972
- return task?.draft === true || status === "draft" || status === "todo";
973
- })
974
- .sort((a, b) => taskSortScore(b) - taskSortScore(a));
1001
+ if (!mountedRef.current || latestTaskRequestRef.current !== requestId) return;
1002
+ const choices = normalizeDispatchTaskChoices(res?.data);
975
1003
  setTaskChoices(choices);
976
1004
  })
977
1005
  .catch(() => {
1006
+ if (!mountedRef.current || latestTaskRequestRef.current !== requestId) return;
978
1007
  setTaskChoices([]);
979
1008
  })
980
1009
  .finally(() => {
1010
+ if (!mountedRef.current || latestTaskRequestRef.current !== requestId) return;
981
1011
  setTasksLoading(false);
982
1012
  });
983
1013
  }, []);
@@ -1045,8 +1075,8 @@ function DispatchSection({ freeSlots, inputRef, className = "" }) {
1045
1075
  <option value="">
1046
1076
  ${tasksLoading ? "Loading tasks…" : "Select backlog or draft task"}
1047
1077
  </option>
1048
- ${taskChoices.map((task) => html`
1049
- <option key=${task.id} value=${task.id}>
1078
+ ${taskChoices.map((task, i) => html`
1079
+ <option key=${`${task.id}-${i}`} value=${task.id}>
1050
1080
  ${formatTaskOptionLabel(task)}
1051
1081
  </option>
1052
1082
  `)}
@@ -1065,7 +1095,7 @@ function DispatchSection({ freeSlots, inputRef, className = "" }) {
1065
1095
  disabled=${!canDispatch || dispatching}
1066
1096
  onClick=${handleDispatch}
1067
1097
  >
1068
- ${dispatching ? "Dispatching…" : iconText("🚀 Dispatch")}
1098
+ ${dispatching ? "Dispatching…" : iconText(":rocket: Dispatch")}
1069
1099
  </button>
1070
1100
  </div>
1071
1101
  <//>
@@ -1322,7 +1352,7 @@ export function AgentsTab() {
1322
1352
 
1323
1353
  <div class="fleet-quick-actions">
1324
1354
  <button class="btn btn-primary btn-sm" onClick=${handleFocusDispatch}>
1325
- ${iconText("🚀 Dispatch")}
1355
+ ${iconText(":rocket: Dispatch")}
1326
1356
  </button>
1327
1357
  <button class="btn btn-secondary btn-sm" onClick=${handleFleetRefresh}>
1328
1358
  ↻ Refresh
@@ -1331,7 +1361,7 @@ export function AgentsTab() {
1331
1361
  class="btn btn-ghost btn-sm"
1332
1362
  onClick=${() => navigateTo("logs")}
1333
1363
  >
1334
- ${iconText("📄 Logs")}
1364
+ ${iconText(":file: Logs")}
1335
1365
  </button>
1336
1366
  </div>
1337
1367
  <//>
@@ -1355,7 +1385,7 @@ export function AgentsTab() {
1355
1385
  const st = slot ? slot.status || "busy" : "idle";
1356
1386
  return html`
1357
1387
  <div
1358
- key=${i}
1388
+ key=${fleetSlotKey(i)}
1359
1389
  class="slot-cell slot-${st}"
1360
1390
  title=${slot
1361
1391
  ? `${slot.taskTitle || slot.taskId} (${st})`
@@ -1387,8 +1417,8 @@ export function AgentsTab() {
1387
1417
  : "No active slots"}
1388
1418
  </div>
1389
1419
  ${slots.length
1390
- ? slots.map(
1391
- (slot, i) => html`
1420
+ ? slots.map(
1421
+ (slot, i) => html`
1392
1422
  <div
1393
1423
  key=${slot?.taskId || slot?.sessionId || `slot-${i}`}
1394
1424
  class="task-card fleet-agent-card ${expandedSlot === i
@@ -1468,7 +1498,7 @@ export function AgentsTab() {
1468
1498
  (slot.taskId || slot.branch || "").slice(0, 12),
1469
1499
  )}
1470
1500
  >
1471
- ${iconText("📄 Logs")}
1501
+ ${iconText(":file: Logs")}
1472
1502
  </button>
1473
1503
  <button
1474
1504
  class="btn btn-ghost btn-sm"
@@ -1477,19 +1507,19 @@ export function AgentsTab() {
1477
1507
  `/steer focus on ${slot.taskTitle || slot.taskId}`,
1478
1508
  )}
1479
1509
  >
1480
- ${iconText("🎯 Steer")}
1510
+ ${iconText(":target: Steer")}
1481
1511
  </button>
1482
1512
  <button
1483
1513
  class="btn btn-ghost btn-sm"
1484
1514
  onClick=${() => openWorkspace(slot, i)}
1485
1515
  >
1486
- ${iconText("🔍 View")}
1516
+ ${iconText(":search: View")}
1487
1517
  </button>
1488
1518
  <button
1489
1519
  class="btn btn-danger btn-sm"
1490
1520
  onClick=${() => handleForceStop({ ...slot, index: i })}
1491
1521
  >
1492
- ${iconText(" Stop")}
1522
+ ${iconText(":ban: Stop")}
1493
1523
  </button>
1494
1524
  </div>
1495
1525
  </div>
@@ -1639,15 +1669,15 @@ function ContextViewer({ sessionId }) {
1639
1669
 
1640
1670
  if (error) {
1641
1671
  return html`<div class="chat-view chat-empty-state">
1642
- <div class="session-empty-icon" style="color:var(--color-error)">${resolveIcon("⚠️")}</div>
1672
+ <div class="session-empty-icon" style="color:var(--color-error)">${resolveIcon(":alert:")}</div>
1643
1673
  <div class="session-empty-text">${error}</div>
1644
- <button class="btn btn-primary btn-sm mt-sm" onClick=${() => { setLoading(true); setError(null); fetchContext(); }}>${iconText("🔄 Retry")}</button>
1674
+ <button class="btn btn-primary btn-sm mt-sm" onClick=${() => { setLoading(true); setError(null); fetchContext(); }}>${iconText(":refresh: Retry")}</button>
1645
1675
  </div>`;
1646
1676
  }
1647
1677
 
1648
1678
  if (!ctx?.context) {
1649
1679
  return html`<div class="chat-view chat-empty-state">
1650
- <div class="session-empty-icon">${resolveIcon("📋")}</div>
1680
+ <div class="session-empty-icon">${resolveIcon(":clipboard:")}</div>
1651
1681
  <div class="session-empty-text">No context available for this session</div>
1652
1682
  </div>`;
1653
1683
  }
@@ -1895,10 +1925,10 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1895
1925
  </div>
1896
1926
  <div class="btn-row">
1897
1927
  <button class="btn btn-ghost btn-sm" onClick=${() => onOpenWorkspace(selectedEntry.slot, selectedEntry.index)}>
1898
- ${iconText("🔍 Workspace")}
1928
+ ${iconText(":search: Workspace")}
1899
1929
  </button>
1900
1930
  <button class="btn btn-danger btn-sm" onClick=${() => onForceStop({ ...selectedEntry.slot, index: selectedEntry.index })}>
1901
- ${iconText(" Stop")}
1931
+ ${iconText(":ban: Stop")}
1902
1932
  </button>
1903
1933
  </div>
1904
1934
  </div>
@@ -1906,19 +1936,19 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1906
1936
  <button
1907
1937
  class="session-detail-tab ${detailTab === "stream" ? "active" : ""}"
1908
1938
  onClick=${() => setDetailTab("stream")}
1909
- >${iconText("💬 Stream")}</button>
1939
+ >${iconText(":chat: Stream")}</button>
1910
1940
  <button
1911
1941
  class="session-detail-tab ${detailTab === "context" ? "active" : ""}"
1912
1942
  onClick=${() => setDetailTab("context")}
1913
- >${iconText("📋 Context")}</button>
1943
+ >${iconText(":clipboard: Context")}</button>
1914
1944
  <button
1915
1945
  class="session-detail-tab ${detailTab === "diff" ? "active" : ""}"
1916
1946
  onClick=${() => setDetailTab("diff")}
1917
- >${iconText("📝 Diff")}</button>
1947
+ >${iconText(":edit: Diff")}</button>
1918
1948
  <button
1919
1949
  class="session-detail-tab ${detailTab === "logs" ? "active" : ""}"
1920
1950
  onClick=${() => setDetailTab("logs")}
1921
- >${iconText("📄 Logs")}</button>
1951
+ >${iconText(":file: Logs")}</button>
1922
1952
  </div>
1923
1953
  <div class="fleet-session-body">
1924
1954
  ${detailTab === "stream"
@@ -1926,7 +1956,7 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1926
1956
  ? html`<${ChatView} sessionId=${sessionId} readOnly=${true} />`
1927
1957
  : html`
1928
1958
  <div class="chat-view chat-empty-state">
1929
- <div class="session-empty-icon">${resolveIcon("💬")}</div>
1959
+ <div class="session-empty-icon">${resolveIcon(":chat:")}</div>
1930
1960
  <div class="session-empty-text">No linked chat session found for this slot</div>
1931
1961
  </div>
1932
1962
  `
@@ -1935,7 +1965,7 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1935
1965
  ? html`<${ContextViewer} sessionId=${contextId} />`
1936
1966
  : html`
1937
1967
  <div class="chat-view chat-empty-state">
1938
- <div class="session-empty-icon">${resolveIcon("📋")}</div>
1968
+ <div class="session-empty-icon">${resolveIcon(":clipboard:")}</div>
1939
1969
  <div class="session-empty-text">No context source available</div>
1940
1970
  </div>
1941
1971
  `
@@ -1944,7 +1974,7 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1944
1974
  ? html`<${DiffViewer} sessionId=${sessionId} />`
1945
1975
  : html`
1946
1976
  <div class="chat-view chat-empty-state">
1947
- <div class="session-empty-icon">${resolveIcon("📝")}</div>
1977
+ <div class="session-empty-icon">${resolveIcon(":edit:")}</div>
1948
1978
  <div class="session-empty-text">Diff requires a linked session</div>
1949
1979
  </div>
1950
1980
  `
@@ -1955,7 +1985,7 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1955
1985
  `
1956
1986
  : html`
1957
1987
  <div class="chat-view chat-empty-state">
1958
- <div class="session-empty-icon">${resolveIcon("💬")}</div>
1988
+ <div class="session-empty-icon">${resolveIcon(":chat:")}</div>
1959
1989
  <div class="session-empty-text">Select a slot to open full session view</div>
1960
1990
  </div>
1961
1991
  `}
package/ui/tabs/chat.js CHANGED
@@ -54,8 +54,7 @@ import { routeParams, setRouteParams } from "../modules/router.js";
54
54
  import { ChatView } from "../components/chat-view.js";
55
55
  import { apiFetch } from "../modules/api.js";
56
56
  import { showToast } from "../modules/state.js";
57
- import { VoiceMicButton } from "../modules/voice.js";
58
- import { VoiceOverlay } from "../modules/voice-overlay.js";
57
+ import { VoiceMicButton, requestVoiceModeOpen } from "../modules/voice.js";
59
58
  import { iconText, resolveIcon } from "../modules/icon-utils.js";
60
59
  import {
61
60
  ChatInputToolbar,
@@ -246,8 +245,6 @@ export function ChatTab() {
246
245
  const [slashActiveIdx, setSlashActiveIdx] = useState(0);
247
246
  const [renamingSessionId, setRenamingSessionId] = useState(null);
248
247
  const [sending, setSending] = useState(false);
249
- const [voiceModeOpen, setVoiceModeOpen] = useState(false);
250
- const [voiceConfig, setVoiceConfig] = useState(null);
251
248
  const [isMobile, setIsMobile] = useState(() => {
252
249
  try {
253
250
  return globalThis.matchMedia?.("(max-width: 768px)")?.matches ?? false;
@@ -307,14 +304,6 @@ export function ChatTab() {
307
304
  };
308
305
  }, []);
309
306
 
310
- /* ── Fetch voice config on mount ── */
311
- useEffect(() => {
312
- fetch("/api/voice/config")
313
- .then(r => r.ok ? r.json() : null)
314
- .then(cfg => setVoiceConfig(cfg))
315
- .catch(() => setVoiceConfig(null));
316
- }, []);
317
-
318
307
  /* ── Track mobile viewport to avoid auto-select loops ── */
319
308
  useEffect(() => {
320
309
  const mq = globalThis.matchMedia?.("(max-width: 768px)");
@@ -536,7 +525,7 @@ export function ChatTab() {
536
525
  method: "POST",
537
526
  body: JSON.stringify({ command: cmdBase, args: cmdArgs }),
538
527
  });
539
- const resultText = resp?.result || resp?.data || `✅ SDK command executed: ${cmdBase}`;
528
+ const resultText = resp?.result || resp?.data || `:check: SDK command executed: ${cmdBase}`;
540
529
  if (sessionId) {
541
530
  const { sessionMessages } = await import("../components/session-list.js");
542
531
  const now = new Date().toISOString();
@@ -560,7 +549,7 @@ export function ChatTab() {
560
549
  const msgs = sessionMessages.value || [];
561
550
  const userMsg = { id: `cmd-${Date.now()}`, role: "user", content, timestamp: now };
562
551
  const resultText = data?.content || data?.error
563
- || (data?.readOnly ? `✅ ${cmdBase} — see the relevant tab for details.` : `✅ Command executed: ${cmdBase}`);
552
+ || (data?.readOnly ? `:check: ${cmdBase} — see the relevant tab for details.` : `:check: Command executed: ${cmdBase}`);
564
553
  const sysMsg = { id: `cmd-r-${Date.now()}`, role: "system", content: resultText, timestamp: now };
565
554
  sessionMessages.value = [...msgs, userMsg, sysMsg];
566
555
  } else {
@@ -702,6 +691,20 @@ export function ChatTab() {
702
691
  await createSession({ type: "primary" });
703
692
  }
704
693
 
694
+ const openMeetingRoom = useCallback(
695
+ (call = "voice") => {
696
+ requestVoiceModeOpen({
697
+ call: call === "video" ? "video" : "voice",
698
+ sessionId: sessionId || undefined,
699
+ initialVisionSource: call === "video" ? "camera" : null,
700
+ executor: activeAgent.value || undefined,
701
+ mode: agentMode.value || undefined,
702
+ model: selectedModel.value || undefined,
703
+ });
704
+ },
705
+ [sessionId],
706
+ );
707
+
705
708
  /* ── Show/expand sessions: on mobile toggles drawer, on desktop fires rail-expand event ── */
706
709
  const handleShowSessions = useCallback(() => {
707
710
  if (isMobile) {
@@ -784,13 +787,29 @@ export function ChatTab() {
784
787
  <div class="chat-shell-inner">
785
788
  <!-- Sessions toggle: shown on mobile always; on desktop only when rail is collapsed (CSS-controlled) -->
786
789
  <button class="session-drawer-btn session-drawer-btn-rail" onClick=${handleShowSessions}>
787
- ${iconText(" Sessions")}
790
+ ${iconText(":menu: Sessions")}
788
791
  </button>
789
792
  <div class="chat-shell-title">
790
793
  <div class="chat-shell-name">${sessionTitle}</div>
791
794
  <div class="chat-shell-meta">${sessionMeta || "Session"}</div>
792
795
  </div>
793
796
  <div class="chat-shell-actions">
797
+ <button
798
+ class="btn btn-ghost btn-sm"
799
+ onClick=${() => openMeetingRoom("voice")}
800
+ title="Start voice meeting for this session"
801
+ >
802
+ <span class="btn-icon">${resolveIcon("phone")}</span>
803
+ Call
804
+ </button>
805
+ <button
806
+ class="btn btn-ghost btn-sm"
807
+ onClick=${() => openMeetingRoom("video")}
808
+ title="Start video meeting for this session"
809
+ >
810
+ <span class="btn-icon">${resolveIcon("camera")}</span>
811
+ Video
812
+ </button>
794
813
  ${isDesktop &&
795
814
  html`
796
815
  <button
@@ -858,37 +877,23 @@ export function ChatTab() {
858
877
  onKeyDown=${handleKeyDown}
859
878
  />
860
879
  <${VoiceMicButton}
861
- onTranscript=${(t) => {
862
- setInputValue((prev) => (prev ? prev + " " + t : t));
863
- if (textareaRef.current) textareaRef.current.focus();
864
- }}
865
880
  disabled=${sending}
866
- title="Voice input"
881
+ title="Live voice mode"
867
882
  />
868
- ${voiceConfig?.available && html`
869
- <button
870
- class="chat-send-btn"
871
- onClick=${() => { setVoiceModeOpen(true); }}
872
- title="Voice mode (${voiceConfig.tier === 1 ? 'Realtime' : 'Fallback'})"
873
- style="background: linear-gradient(135deg, rgba(99,102,241,0.2), rgba(16,185,129,0.2)); border-color: rgba(99,102,241,0.3);"
874
- >
875
- ${resolveIcon("headphones") || "\uD83C\uDFA7"}
876
- </button>
877
- `}
878
883
  <button
879
884
  class="chat-send-btn"
880
885
  disabled=${!inputValue.trim() || sending}
881
886
  onClick=${handleSend}
882
887
  title="Send (Enter)"
883
888
  >
884
- ${resolveIcon(sending ? "" : "➤")}
889
+ ${resolveIcon(sending ? ":clock:" : "➤")}
885
890
  </button>
886
891
  </div>
887
892
  <div class="chat-input-hint">
888
893
  <span>Shift+Enter for new line</span>
889
894
  <span>Type / for commands</span>
890
895
  ${offlineQueueSize.peek() > 0 && html`
891
- <span class="chat-offline-badge">${iconText(`📤 ${offlineQueueSize.peek()} queued`)}</span>
896
+ <span class="chat-offline-badge">${iconText(`:upload: ${offlineQueueSize.peek()} queued`)}</span>
892
897
  `}
893
898
  </div>
894
899
  </div>
@@ -908,14 +913,6 @@ export function ChatTab() {
908
913
  onClick=${() => setDrawerOpen(false)}
909
914
  ></div>
910
915
  `}
911
- ${voiceModeOpen && html`
912
- <${VoiceOverlay}
913
- visible=${voiceModeOpen}
914
- onClose=${() => setVoiceModeOpen(false)}
915
- tier=${voiceConfig?.tier || 2}
916
- sessionId=${sessionId}
917
- />
918
- `}
919
916
  </div>
920
917
  `;
921
918
  }
@@ -856,7 +856,7 @@ export function ControlTab() {
856
856
  sendCmd(planFocus ? `/plan ${n} ${planFocus}` : `/plan ${n}`);
857
857
  }}
858
858
  >
859
- ${iconText("📋 Plan")}
859
+ ${iconText(":clipboard: Plan")}
860
860
  </button>
861
861
  </div>
862
862
  </div>
@@ -927,7 +927,7 @@ export function ControlTab() {
927
927
  style="flex:1"
928
928
  />
929
929
  <button class="btn btn-secondary btn-sm" onClick=${handleQuickCmd}>
930
- ${iconText(" Run")}
930
+ ${iconText(":play: Run")}
931
931
  </button>
932
932
  </div>
933
933
  ${quickCmdFeedback && html`
@@ -587,7 +587,7 @@ export function DashboardTab() {
587
587
  Your AI development fleet is ready. Create your first task to get started.
588
588
  </div>
589
589
  <button class="btn btn-primary" onClick=${() => setShowCreate(true)}>
590
- ${iconText(" Create your first task")}
590
+ ${iconText(":plus: Create your first task")}
591
591
  </button>
592
592
  </div>
593
593
  <//>