bosun 0.36.1 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
@@ -434,6 +434,18 @@ function buildStableSetupDefaults({
434
434
  workflowRunStuckThresholdMs: 300000,
435
435
  workflowMaxPersistedRuns: 200,
436
436
  workflowMaxConcurrentBranches: 8,
437
+ voiceEnabled: true,
438
+ voiceProvider: "auto",
439
+ voiceModel: "gpt-4o-realtime-preview-2024-12-17",
440
+ voiceVisionModel: "gpt-4.1-mini",
441
+ voiceId: "alloy",
442
+ voiceTurnDetection: "server_vad",
443
+ voiceFallbackMode: "browser",
444
+ voiceDelegateExecutor: "codex-sdk",
445
+ openaiRealtimeApiKey: "",
446
+ azureOpenaiRealtimeEndpoint: "",
447
+ azureOpenaiRealtimeApiKey: "",
448
+ azureOpenaiRealtimeDeployment: "gpt-4o-realtime-preview",
437
449
  copilotEnableAllMcpTools: false,
438
450
  // Backward-compatible fields consumed by older setup UI revisions.
439
451
  distribution: "primary-only",
@@ -818,6 +830,122 @@ function applyNonBlockingSetupEnvDefaults(envMap, env = {}, sourceEnv = process.
818
830
  ["workspace-write", "danger-full-access", "read-only"],
819
831
  "workspace-write",
820
832
  );
833
+ envMap.VOICE_ENABLED = toBooleanEnvString(
834
+ pickNonEmptyValue(
835
+ env.voiceEnabled,
836
+ env.VOICE_ENABLED,
837
+ envMap.VOICE_ENABLED,
838
+ sourceEnv.VOICE_ENABLED,
839
+ ),
840
+ true,
841
+ );
842
+ envMap.VOICE_PROVIDER = normalizeEnumValue(
843
+ pickNonEmptyValue(
844
+ env.voiceProvider,
845
+ env.VOICE_PROVIDER,
846
+ envMap.VOICE_PROVIDER,
847
+ sourceEnv.VOICE_PROVIDER,
848
+ ),
849
+ ["auto", "openai", "azure", "claude", "gemini", "fallback"],
850
+ "auto",
851
+ );
852
+ envMap.VOICE_MODEL = String(
853
+ pickNonEmptyValue(
854
+ env.voiceModel,
855
+ env.VOICE_MODEL,
856
+ envMap.VOICE_MODEL,
857
+ sourceEnv.VOICE_MODEL,
858
+ ) || "gpt-4o-realtime-preview-2024-12-17",
859
+ ).trim() || "gpt-4o-realtime-preview-2024-12-17";
860
+ envMap.VOICE_VISION_MODEL = String(
861
+ pickNonEmptyValue(
862
+ env.voiceVisionModel,
863
+ env.VOICE_VISION_MODEL,
864
+ envMap.VOICE_VISION_MODEL,
865
+ sourceEnv.VOICE_VISION_MODEL,
866
+ ) || "gpt-4.1-mini",
867
+ ).trim() || "gpt-4.1-mini";
868
+ envMap.VOICE_ID = normalizeEnumValue(
869
+ pickNonEmptyValue(
870
+ env.voiceId,
871
+ env.VOICE_ID,
872
+ envMap.VOICE_ID,
873
+ sourceEnv.VOICE_ID,
874
+ ),
875
+ ["alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"],
876
+ "alloy",
877
+ );
878
+ envMap.VOICE_TURN_DETECTION = normalizeEnumValue(
879
+ pickNonEmptyValue(
880
+ env.voiceTurnDetection,
881
+ env.VOICE_TURN_DETECTION,
882
+ envMap.VOICE_TURN_DETECTION,
883
+ sourceEnv.VOICE_TURN_DETECTION,
884
+ ),
885
+ ["server_vad", "semantic_vad", "none"],
886
+ "server_vad",
887
+ );
888
+ envMap.VOICE_FALLBACK_MODE = normalizeEnumValue(
889
+ pickNonEmptyValue(
890
+ env.voiceFallbackMode,
891
+ env.VOICE_FALLBACK_MODE,
892
+ envMap.VOICE_FALLBACK_MODE,
893
+ sourceEnv.VOICE_FALLBACK_MODE,
894
+ ),
895
+ ["browser", "disabled"],
896
+ "browser",
897
+ );
898
+ envMap.VOICE_DELEGATE_EXECUTOR = normalizeEnumValue(
899
+ pickNonEmptyValue(
900
+ env.voiceDelegateExecutor,
901
+ env.VOICE_DELEGATE_EXECUTOR,
902
+ envMap.VOICE_DELEGATE_EXECUTOR,
903
+ sourceEnv.VOICE_DELEGATE_EXECUTOR,
904
+ ),
905
+ ["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"],
906
+ "codex-sdk",
907
+ );
908
+
909
+ const openaiRealtimeApiKey = pickNonEmptyValue(
910
+ env.openaiRealtimeApiKey,
911
+ env.OPENAI_REALTIME_API_KEY,
912
+ envMap.OPENAI_REALTIME_API_KEY,
913
+ sourceEnv.OPENAI_REALTIME_API_KEY,
914
+ );
915
+ if (openaiRealtimeApiKey !== undefined) {
916
+ envMap.OPENAI_REALTIME_API_KEY = String(openaiRealtimeApiKey).trim();
917
+ }
918
+
919
+ const azureRealtimeEndpoint = pickNonEmptyValue(
920
+ env.azureOpenaiRealtimeEndpoint,
921
+ env.AZURE_OPENAI_REALTIME_ENDPOINT,
922
+ envMap.AZURE_OPENAI_REALTIME_ENDPOINT,
923
+ sourceEnv.AZURE_OPENAI_REALTIME_ENDPOINT,
924
+ sourceEnv.AZURE_OPENAI_ENDPOINT,
925
+ );
926
+ if (azureRealtimeEndpoint !== undefined) {
927
+ envMap.AZURE_OPENAI_REALTIME_ENDPOINT = String(azureRealtimeEndpoint).trim();
928
+ }
929
+
930
+ const azureRealtimeApiKey = pickNonEmptyValue(
931
+ env.azureOpenaiRealtimeApiKey,
932
+ env.AZURE_OPENAI_REALTIME_API_KEY,
933
+ envMap.AZURE_OPENAI_REALTIME_API_KEY,
934
+ sourceEnv.AZURE_OPENAI_REALTIME_API_KEY,
935
+ sourceEnv.AZURE_OPENAI_API_KEY,
936
+ );
937
+ if (azureRealtimeApiKey !== undefined) {
938
+ envMap.AZURE_OPENAI_REALTIME_API_KEY = String(azureRealtimeApiKey).trim();
939
+ }
940
+
941
+ envMap.AZURE_OPENAI_REALTIME_DEPLOYMENT = String(
942
+ pickNonEmptyValue(
943
+ env.azureOpenaiRealtimeDeployment,
944
+ env.AZURE_OPENAI_REALTIME_DEPLOYMENT,
945
+ envMap.AZURE_OPENAI_REALTIME_DEPLOYMENT,
946
+ sourceEnv.AZURE_OPENAI_REALTIME_DEPLOYMENT,
947
+ ) || "gpt-4o-realtime-preview",
948
+ ).trim() || "gpt-4o-realtime-preview";
821
949
 
822
950
  envMap.CONTAINER_ENABLED = toBooleanEnvString(
823
951
  pickNonEmptyValue(env.containerEnabled, envMap.CONTAINER_ENABLED, sourceEnv.CONTAINER_ENABLED),
package/telegram-bot.mjs CHANGED
@@ -2178,6 +2178,83 @@ const TELEGRAM_ICON_TOKEN_LABELS = Object.freeze({
2178
2178
  cloud: "Cloud",
2179
2179
  });
2180
2180
 
2181
+ const TELEGRAM_ICON_TOKEN_EMOJI = Object.freeze({
2182
+ check: "✅",
2183
+ close: "❌",
2184
+ alert: "⚠️",
2185
+ pause: "⏸️",
2186
+ play: "▶️",
2187
+ stop: "⏹️",
2188
+ refresh: "🔄",
2189
+ chart: "📊",
2190
+ clipboard: "📋",
2191
+ bot: "🤖",
2192
+ git: "🌿",
2193
+ settings: "⚙️",
2194
+ server: "🖧",
2195
+ folder: "📁",
2196
+ file: "📄",
2197
+ phone: "📱",
2198
+ globe: "🌐",
2199
+ heart: "❤️",
2200
+ cpu: "🧠",
2201
+ chat: "💬",
2202
+ hash: "#️⃣",
2203
+ repeat: "🔁",
2204
+ beaker: "🧪",
2205
+ compass: "🧭",
2206
+ target: "🎯",
2207
+ workflow: "🧩",
2208
+ arrowright: "➡️",
2209
+ plus: "➕",
2210
+ menu: "☰",
2211
+ lock: "🔒",
2212
+ unlock: "🔓",
2213
+ search: "🔍",
2214
+ link: "🔗",
2215
+ upload: "📤",
2216
+ download: "📥",
2217
+ box: "📦",
2218
+ bell: "🔔",
2219
+ lightbulb: "💡",
2220
+ rocket: "🚀",
2221
+ home: "🏠",
2222
+ pin: "📌",
2223
+ star: "⭐",
2224
+ help: "❓",
2225
+ cloud: "☁️",
2226
+ monitor: "🖥️",
2227
+ eye: "👁️",
2228
+ edit: "✏️",
2229
+ trash: "🗑️",
2230
+ dot: "•",
2231
+ });
2232
+
2233
+ function decodeUnicodeIconToken(name) {
2234
+ const raw = String(name || "").trim();
2235
+ const match = raw.match(/^u([0-9a-f]{4,6})$/i);
2236
+ if (!match) return "";
2237
+ const codePoint = Number.parseInt(match[1], 16);
2238
+ if (!Number.isFinite(codePoint)) return "";
2239
+ try {
2240
+ return String.fromCodePoint(codePoint);
2241
+ } catch {
2242
+ return "";
2243
+ }
2244
+ }
2245
+
2246
+ function resolveTelegramIconTokenGlyph(name) {
2247
+ const raw = String(name || "").trim();
2248
+ if (!raw) return "";
2249
+ const lowered = raw.toLowerCase();
2250
+ const squashed = lowered.replace(/[_-]+/g, "");
2251
+ const glyph = TELEGRAM_ICON_TOKEN_EMOJI[lowered]
2252
+ || TELEGRAM_ICON_TOKEN_EMOJI[squashed]
2253
+ || decodeUnicodeIconToken(lowered)
2254
+ || decodeUnicodeIconToken(squashed);
2255
+ return glyph || "";
2256
+ }
2257
+
2181
2258
  function humanizeIconTokenName(name) {
2182
2259
  const spaced = String(name || "")
2183
2260
  .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
@@ -2195,6 +2272,8 @@ function formatTelegramIconTokens(value, { button = false } = {}) {
2195
2272
  const str = String(value);
2196
2273
  return str.replace(/:([a-zA-Z][a-zA-Z0-9_-]*):/g, (_match, rawName) => {
2197
2274
  const tokenName = String(rawName || "");
2275
+ const glyph = resolveTelegramIconTokenGlyph(tokenName);
2276
+ if (glyph) return glyph;
2198
2277
  const key = tokenName.toLowerCase();
2199
2278
  const label = TELEGRAM_ICON_TOKEN_LABELS[tokenName]
2200
2279
  || TELEGRAM_ICON_TOKEN_LABELS[key]
@@ -4005,8 +4084,12 @@ function appendRefreshRow(keyboard, screenId, params = {}) {
4005
4084
  )
4006
4085
  : uiGoAction(screenId, params.page);
4007
4086
  const rows = keyboard.inline_keyboard || [];
4087
+ const refreshLabel = formatTelegramIconTokens(":refresh: Refresh", { button: true });
4008
4088
  const hasRefresh = rows.some((row) =>
4009
- row.some((btn) => btn?.text === ":refresh: Refresh"),
4089
+ row.some((btn) =>
4090
+ btn?.text === ":refresh: Refresh"
4091
+ || btn?.text === refreshLabel
4092
+ || btn?.callback_data === action),
4010
4093
  );
4011
4094
  if (hasRefresh) return keyboard;
4012
4095
  return {
package/ui/app.js CHANGED
@@ -61,6 +61,7 @@ const TABLET_MIN_WIDTH = 768;
61
61
  const COMPACT_NAV_MAX_WIDTH = 520;
62
62
  const RAIL_ICON_WIDTH = 54;
63
63
  const SIDEBAR_ICON_WIDTH = 54;
64
+ const APP_LOGO_SOURCES = ["/logo.png", "/logo.svg", "/favicon.png"];
64
65
  const VOICE_LAUNCH_QUERY_KEYS = [
65
66
  "launch",
66
67
  "call",
@@ -74,6 +75,30 @@ const VOICE_LAUNCH_QUERY_KEYS = [
74
75
  "chat_id",
75
76
  ];
76
77
 
78
+ function getAppLogoSource(index = 0) {
79
+ const safeIndex = Number.isFinite(index) ? Math.trunc(index) : 0;
80
+ if (safeIndex <= 0) return APP_LOGO_SOURCES[0];
81
+ if (safeIndex >= APP_LOGO_SOURCES.length) {
82
+ return APP_LOGO_SOURCES[APP_LOGO_SOURCES.length - 1];
83
+ }
84
+ return APP_LOGO_SOURCES[safeIndex];
85
+ }
86
+
87
+ function handleAppLogoLoadError(event) {
88
+ const target = event?.currentTarget;
89
+ if (!target) return;
90
+
91
+ const currentIndex = Number.parseInt(
92
+ String(target.dataset?.logoFallbackIndex || "0"),
93
+ 10,
94
+ );
95
+ const nextIndex = Number.isFinite(currentIndex) ? currentIndex + 1 : 1;
96
+ if (nextIndex >= APP_LOGO_SOURCES.length) return;
97
+
98
+ target.dataset.logoFallbackIndex = String(nextIndex);
99
+ target.src = getAppLogoSource(nextIndex);
100
+ }
101
+
77
102
  function parseVoiceLaunchFromUrl() {
78
103
  if (typeof window === "undefined") return null;
79
104
  const params = new URLSearchParams(window.location.search || "");
@@ -586,7 +611,13 @@ function SidebarNav({ collapsed = false, onToggle }) {
586
611
  <div class="sidebar-brand-row">
587
612
  <div class="sidebar-brand">
588
613
  <div class="sidebar-logo">
589
- <img src="logo.png" alt="Bosun" class="app-logo-img" />
614
+ <img
615
+ src=${getAppLogoSource(0)}
616
+ alt="Bosun"
617
+ class="app-logo-img"
618
+ data-logo-fallback-index="0"
619
+ onError=${handleAppLogoLoadError}
620
+ />
590
621
  </div>
591
622
  ${!collapsed && html`<div class="sidebar-title">Bosun</div>`}
592
623
  </div>
@@ -2398,7 +2398,7 @@ function Header() {
2398
2398
  return html`
2399
2399
  <header class="app-header">
2400
2400
  <div class="app-header-brand">
2401
- <img src="logo.png" alt="Bosun" class="app-logo-img" />
2401
+ <img src="/logo.png" alt="Bosun" class="app-logo-img" />
2402
2402
  <div class="app-header-title">Bosun</div>
2403
2403
  </div>
2404
2404
  <div class="connection-pill ${isConn ? "connected" : "disconnected"}">
@@ -438,6 +438,42 @@ const TraceEvent = memo(function TraceEvent({ msg }) {
438
438
  `;
439
439
  }, (prev, next) => prev.msg === next.msg);
440
440
 
441
+ /* ─── ThinkingGroup — collapses consecutive trace events into one row ─── */
442
+ const ThinkingGroup = memo(function ThinkingGroup({ msgs }) {
443
+ const hasErrors = msgs.some((m) => m.type === "error" || m.type === "stream_error");
444
+ const [expanded, setExpanded] = useState(hasErrors);
445
+
446
+ useEffect(() => {
447
+ if (hasErrors) setExpanded(true);
448
+ }, [msgs.length, hasErrors]);
449
+
450
+ const toolCount = msgs.filter((m) => m.type === "tool_call").length;
451
+ const stepCount = msgs.filter((m) => {
452
+ const t = (m.type || "").toLowerCase();
453
+ return !["tool_call", "tool_result", "tool_output", "error", "stream_error"].includes(t);
454
+ }).length;
455
+
456
+ const parts = [];
457
+ if (toolCount) parts.push(`${toolCount} tool call${toolCount !== 1 ? "s" : ""}`);
458
+ if (stepCount) parts.push(`${stepCount} step${stepCount !== 1 ? "s" : ""}`);
459
+ const label = parts.join(", ") || `${msgs.length} step${msgs.length !== 1 ? "s" : ""}`;
460
+
461
+ return html`
462
+ <div class="thinking-group ${expanded ? "expanded" : ""} ${hasErrors ? "has-errors" : ""}">
463
+ <button class="thinking-group-head" type="button" onClick=${() => setExpanded((p) => !p)}>
464
+ <span class="thinking-group-badge">${iconText(":cpu: Thinking")}</span>
465
+ <span class="thinking-group-label">${label}</span>
466
+ <span class="thinking-group-chevron">${expanded ? "▾" : "▸"}</span>
467
+ </button>
468
+ ${expanded && html`
469
+ <div class="thinking-group-body">
470
+ ${msgs.map((m, idx) => html`<${TraceEvent} key=${m.id || m.timestamp || idx} msg=${m} />`)}
471
+ </div>
472
+ `}
473
+ </div>
474
+ `;
475
+ }, (prev, next) => prev.msgs === next.msgs);
476
+
441
477
  /* ─── Chat View component ─── */
442
478
 
443
479
  export function ChatView({ sessionId, readOnly = false, embedded = false }) {
@@ -541,12 +577,28 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
541
577
  return messageIdentity(latest);
542
578
  }, [filteredMessages]);
543
579
 
580
+ // Count only real (non-trace) messages toward the visible limit so trace
581
+ // events don't consume the page budget.
582
+ const realMessageCount = useMemo(
583
+ () => filteredMessages.filter((msg) => !isTraceEventMessage(msg)).length,
584
+ [filteredMessages],
585
+ );
586
+
544
587
  const visibleMessages = useMemo(() => {
545
- if (filteredMessages.length <= visibleCount) return filteredMessages;
546
- return filteredMessages.slice(-visibleCount);
547
- }, [filteredMessages, visibleCount]);
588
+ if (realMessageCount <= visibleCount) return filteredMessages;
589
+ // Walk backwards counting only real messages; include all trace events
590
+ // that fall between them so groups stay intact.
591
+ let realCount = 0;
592
+ for (let i = filteredMessages.length - 1; i >= 0; i--) {
593
+ if (!isTraceEventMessage(filteredMessages[i])) {
594
+ realCount++;
595
+ if (realCount >= visibleCount) return filteredMessages.slice(i);
596
+ }
597
+ }
598
+ return filteredMessages;
599
+ }, [filteredMessages, visibleCount, realMessageCount]);
548
600
 
549
- const hasMoreMessages = filteredMessages.length > visibleCount;
601
+ const hasMoreMessages = realMessageCount > visibleCount;
550
602
 
551
603
  const streamActivityKey = useMemo(() => {
552
604
  if (filteredMessages.length === 0) return "empty";
@@ -602,16 +654,37 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
602
654
  }
603
655
 
604
656
  const renderItems = useMemo(() => {
605
- return visibleMessages.map((msg, index) => {
606
- const baseKey = msg.id || msg.timestamp || `msg-${index}`;
607
- const trace = isTraceEventMessage(msg);
608
- return {
609
- kind: trace ? "trace" : "message",
610
- key: `${trace ? "trace" : "message"}-${baseKey}-${index}`,
611
- messageKey: messageIdentity(msg),
612
- msg,
613
- };
614
- });
657
+ const items = [];
658
+ let i = 0;
659
+ while (i < visibleMessages.length) {
660
+ const msg = visibleMessages[i];
661
+ if (isTraceEventMessage(msg)) {
662
+ // Collect consecutive trace events; discard completely empty ones.
663
+ const group = [];
664
+ let groupKey = null;
665
+ while (i < visibleMessages.length && isTraceEventMessage(visibleMessages[i])) {
666
+ const m = visibleMessages[i];
667
+ if (messageText(m).trim()) {
668
+ group.push(m);
669
+ if (!groupKey) groupKey = m.id || m.timestamp || `trace-${i}`;
670
+ }
671
+ i++;
672
+ }
673
+ if (group.length > 0) {
674
+ items.push({ kind: "thinking-group", key: `thinking-group-${groupKey}`, msgs: group });
675
+ }
676
+ } else {
677
+ const baseKey = msg.id || msg.timestamp || `msg-${i}`;
678
+ items.push({
679
+ kind: "message",
680
+ key: `message-${baseKey}-${i}`,
681
+ messageKey: messageIdentity(msg),
682
+ msg,
683
+ });
684
+ i++;
685
+ }
686
+ }
687
+ return items;
615
688
  }, [visibleMessages]);
616
689
 
617
690
  const refreshMessages = useCallback(async () => {
@@ -1173,8 +1246,8 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
1173
1246
  </button>
1174
1247
  </div>
1175
1248
  `}
1176
- ${renderItems.map((item) => item.kind === "trace"
1177
- ? html`<${TraceEvent} key=${item.key} msg=${item.msg} />`
1249
+ ${renderItems.map((item) => item.kind === "thinking-group"
1250
+ ? html`<${ThinkingGroup} key=${item.key} msgs=${item.msgs} />`
1178
1251
  : html`<${ChatBubble}
1179
1252
  key=${item.key}
1180
1253
  msg=${item.msg}
@@ -413,6 +413,32 @@ const STATUS_COLOR_MAP = {
413
413
  archived: "var(--text-hint)",
414
414
  };
415
415
 
416
+ const SESSION_VIEW_FILTER = Object.freeze({
417
+ all: "all",
418
+ active: "active",
419
+ historic: "historic",
420
+ });
421
+
422
+ function normalizeSessionViewFilter(value) {
423
+ const normalized = String(value || "").trim().toLowerCase();
424
+ if (normalized === SESSION_VIEW_FILTER.active) return SESSION_VIEW_FILTER.active;
425
+ if (normalized === SESSION_VIEW_FILTER.historic) return SESSION_VIEW_FILTER.historic;
426
+ return SESSION_VIEW_FILTER.all;
427
+ }
428
+
429
+ function getSessionStatusKey(session) {
430
+ return String(session?.status || "idle").trim().toLowerCase();
431
+ }
432
+
433
+ function isActiveSession(session) {
434
+ const status = getSessionStatusKey(session);
435
+ return status === "active" || status === "running";
436
+ }
437
+
438
+ function isHistoricSession(session) {
439
+ return !isActiveSession(session);
440
+ }
441
+
416
442
  /* ─── Swipeable Session Item ─── */
417
443
  function SwipeableSessionItem({
418
444
  session: s,
@@ -639,6 +665,8 @@ export function SessionList({
639
665
  onSelect,
640
666
  showArchived = true,
641
667
  onToggleArchived,
668
+ sessionView = SESSION_VIEW_FILTER.all,
669
+ onSessionViewChange,
642
670
  defaultType = null,
643
671
  renamingSessionId = null,
644
672
  onStartRename,
@@ -647,9 +675,36 @@ export function SessionList({
647
675
  }) {
648
676
  const [search, setSearch] = useState("");
649
677
  const [revealedActions, setRevealedActions] = useState(null);
678
+ const [uncontrolledSessionView, setUncontrolledSessionView] = useState(
679
+ normalizeSessionViewFilter(sessionView),
680
+ );
650
681
  const allSessions = sessionsData.value || [];
651
682
  const error = sessionsError.value;
652
683
  const hasSearch = search.trim().length > 0;
684
+ const resolvedSessionView =
685
+ typeof onSessionViewChange === "function"
686
+ ? normalizeSessionViewFilter(sessionView)
687
+ : uncontrolledSessionView;
688
+
689
+ useEffect(() => {
690
+ if (typeof onSessionViewChange === "function") return;
691
+ const normalized = normalizeSessionViewFilter(sessionView);
692
+ if (normalized !== uncontrolledSessionView) {
693
+ setUncontrolledSessionView(normalized);
694
+ }
695
+ }, [onSessionViewChange, sessionView, uncontrolledSessionView]);
696
+
697
+ const setSessionView = useCallback(
698
+ (nextFilter) => {
699
+ const normalized = normalizeSessionViewFilter(nextFilter);
700
+ if (typeof onSessionViewChange === "function") {
701
+ onSessionViewChange(normalized);
702
+ } else {
703
+ setUncontrolledSessionView(normalized);
704
+ }
705
+ },
706
+ [onSessionViewChange],
707
+ );
653
708
 
654
709
  // Filter by defaultType to exclude ghost sessions (e.g. task sessions in Chat tab)
655
710
  const typeFiltered = defaultType
@@ -666,30 +721,39 @@ export function SessionList({
666
721
  })
667
722
  : allSessions;
668
723
 
669
- const base = showArchived
724
+ const archivedFiltered = showArchived
670
725
  ? typeFiltered
671
- : typeFiltered.filter((s) => s.status !== "archived");
726
+ : typeFiltered.filter((s) => getSessionStatusKey(s) !== "archived");
727
+
728
+ const viewFiltered = archivedFiltered.filter((s) => {
729
+ if (resolvedSessionView === SESSION_VIEW_FILTER.active) {
730
+ return isActiveSession(s);
731
+ }
732
+ if (resolvedSessionView === SESSION_VIEW_FILTER.historic) {
733
+ return isHistoricSession(s);
734
+ }
735
+ return true;
736
+ });
672
737
 
673
738
  const filtered = search
674
- ? base.filter(
739
+ ? viewFiltered.filter(
675
740
  (s) =>
676
741
  (s.title || "").toLowerCase().includes(search.toLowerCase()) ||
677
742
  (s.taskId || "").toLowerCase().includes(search.toLowerCase()),
678
743
  )
679
- : base;
744
+ : viewFiltered;
680
745
 
681
- const active = filtered.filter(
682
- (s) => s.status === "active" || s.status === "running",
683
- );
684
- const archived = filtered.filter((s) => s.status === "archived");
746
+ const active = filtered.filter((s) => isActiveSession(s));
747
+ const archived = filtered.filter((s) => getSessionStatusKey(s) === "archived");
685
748
  const recent = filtered.filter(
686
749
  (s) =>
687
- s.status !== "active" &&
688
- s.status !== "running" &&
689
- s.status !== "archived",
750
+ !isActiveSession(s) && getSessionStatusKey(s) !== "archived",
690
751
  );
691
752
 
692
- const archivedCount = typeFiltered.filter((s) => s.status === "archived").length;
753
+ const archivedCount = typeFiltered.filter((s) => getSessionStatusKey(s) === "archived").length;
754
+ const allCount = archivedFiltered.length;
755
+ const activeCount = archivedFiltered.filter((s) => isActiveSession(s)).length;
756
+ const historicCount = archivedFiltered.filter((s) => isHistoricSession(s)).length;
693
757
 
694
758
  const handleSelect = useCallback(
695
759
  (id) => {
@@ -705,6 +769,13 @@ export function SessionList({
705
769
  loadSessions(_lastLoadFilter);
706
770
  }, []);
707
771
 
772
+ const handleCreateSession = useCallback(() => {
773
+ if (resolvedSessionView === SESSION_VIEW_FILTER.historic) {
774
+ setSessionView(SESSION_VIEW_FILTER.all);
775
+ }
776
+ createSession(defaultType ? { type: defaultType } : {});
777
+ }, [defaultType, resolvedSessionView, setSessionView]);
778
+
708
779
  const handleArchive = useCallback(async (id) => {
709
780
  setRevealedActions(null);
710
781
  await archiveSession(id);
@@ -726,6 +797,21 @@ export function SessionList({
726
797
  setRevealedActions(null);
727
798
  }, []);
728
799
 
800
+ const emptyTitle = hasSearch
801
+ ? "No matching sessions"
802
+ : resolvedSessionView === SESSION_VIEW_FILTER.active
803
+ ? "No active sessions"
804
+ : resolvedSessionView === SESSION_VIEW_FILTER.historic
805
+ ? "No historic sessions"
806
+ : "No sessions yet";
807
+ const emptyHint = hasSearch
808
+ ? "Try a different keyword or clear the search."
809
+ : resolvedSessionView === SESSION_VIEW_FILTER.active
810
+ ? "Start a new session or switch to All."
811
+ : resolvedSessionView === SESSION_VIEW_FILTER.historic
812
+ ? "Historic sessions appear after they finish."
813
+ : "Create a session to get started.";
814
+
729
815
  /* ── Render session items ── */
730
816
  function renderSessionItem(s) {
731
817
  return html`
@@ -783,8 +869,7 @@ export function SessionList({
783
869
  `}
784
870
  <button
785
871
  class="btn btn-primary btn-sm"
786
- onClick=${() =>
787
- createSession(defaultType ? { type: defaultType } : {})}
872
+ onClick=${handleCreateSession}
788
873
  >
789
874
  + New
790
875
  </button>
@@ -800,6 +885,27 @@ export function SessionList({
800
885
  />
801
886
  </div>
802
887
 
888
+ <div style="display:flex;gap:6px;flex-wrap:wrap;padding:0 10px 8px;">
889
+ <button
890
+ class="btn btn-sm ${resolvedSessionView === SESSION_VIEW_FILTER.all ? "btn-primary" : "btn-ghost"}"
891
+ onClick=${() => setSessionView(SESSION_VIEW_FILTER.all)}
892
+ >
893
+ All (${allCount})
894
+ </button>
895
+ <button
896
+ class="btn btn-sm ${resolvedSessionView === SESSION_VIEW_FILTER.active ? "btn-primary" : "btn-ghost"}"
897
+ onClick=${() => setSessionView(SESSION_VIEW_FILTER.active)}
898
+ >
899
+ Active (${activeCount})
900
+ </button>
901
+ <button
902
+ class="btn btn-sm ${resolvedSessionView === SESSION_VIEW_FILTER.historic ? "btn-primary" : "btn-ghost"}"
903
+ onClick=${() => setSessionView(SESSION_VIEW_FILTER.historic)}
904
+ >
905
+ Historic (${historicCount})
906
+ </button>
907
+ </div>
908
+
803
909
  <div class="session-list-scroll">
804
910
  ${active.length > 0 &&
805
911
  html`
@@ -821,18 +927,15 @@ export function SessionList({
821
927
  <div class="session-empty">
822
928
  <div class="session-empty-icon">${resolveIcon(":chat:")}</div>
823
929
  <div class="session-empty-text">
824
- ${hasSearch ? "No matching sessions" : "No sessions yet"}
930
+ ${emptyTitle}
825
931
  <div class="session-empty-subtext">
826
- ${hasSearch
827
- ? "Try a different keyword or clear the search."
828
- : "Create a session to get started."}
932
+ ${emptyHint}
829
933
  </div>
830
934
  </div>
831
935
  <div class="session-empty-actions">
832
936
  <button
833
937
  class="btn btn-primary btn-sm"
834
- onClick=${() =>
835
- createSession(defaultType ? { type: defaultType } : {})}
938
+ onClick=${handleCreateSession}
836
939
  >
837
940
  + New Session
838
941
  </button>
@@ -140,6 +140,19 @@ export async function startVoiceSession(options = {}) {
140
140
  const tokenData = await tokenRes.json();
141
141
 
142
142
  // 2. Get microphone
143
+ const mediaDevices = navigator?.mediaDevices;
144
+ if (!mediaDevices?.getUserMedia) {
145
+ const host = String(globalThis.location?.hostname || "").toLowerCase();
146
+ const localhostLike =
147
+ host === "localhost" || host === "127.0.0.1" || host === "::1";
148
+ if (!globalThis.isSecureContext && !localhostLike) {
149
+ throw new Error(
150
+ "Microphone access requires HTTPS (or localhost). Open the UI via the Cloudflare HTTPS URL or localhost.",
151
+ );
152
+ }
153
+ throw new Error("Microphone API unavailable in this browser/runtime.");
154
+ }
155
+
143
156
  _mediaStream = await navigator.mediaDevices.getUserMedia({
144
157
  audio: {
145
158
  echoCancellation: true,
package/ui/setup.html CHANGED
@@ -344,6 +344,28 @@
344
344
  .success-banner h2 { color: var(--success); margin-bottom: 8px; }
345
345
  .success-banner p { color: var(--text-secondary); font-size: 0.9rem; }
346
346
 
347
+ /* ── Inline icon helpers (shared with iconText output) ───────────── */
348
+ .icon-text {
349
+ display: inline-flex;
350
+ align-items: center;
351
+ gap: 0.35em;
352
+ flex-wrap: wrap;
353
+ }
354
+ .icon-inline {
355
+ display: inline-flex;
356
+ align-items: center;
357
+ justify-content: center;
358
+ width: 1em;
359
+ height: 1em;
360
+ line-height: 1;
361
+ vertical-align: middle;
362
+ }
363
+ .icon-inline svg {
364
+ width: 1em;
365
+ height: 1em;
366
+ display: block;
367
+ }
368
+
347
369
  /* ── Profile Cards ───────────────────────────────────────────────── */
348
370
  .profile-cards {
349
371
  display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
@@ -489,6 +511,7 @@
489
511
  import { h, render } from "preact";
490
512
  import { useState, useEffect, useRef } from "preact/hooks";
491
513
  import htm from "htm";
514
+ import { iconText } from "./modules/icon-utils.js";
492
515
 
493
516
  const html = htm.bind(h);
494
517
 
@@ -679,6 +702,19 @@ function App() {
679
702
  const [whatsappEnabled, setWhatsappEnabled] = useState(false);
680
703
  const [telegramIntervalMin, setTelegramIntervalMin] = useState(10);
681
704
  const [orchestratorScript, setOrchestratorScript] = useState("");
705
+ // Voice assistant
706
+ const [voiceEnabled, setVoiceEnabled] = useState(true);
707
+ const [voiceProvider, setVoiceProvider] = useState("auto");
708
+ const [voiceModel, setVoiceModel] = useState("gpt-4o-realtime-preview-2024-12-17");
709
+ const [voiceVisionModel, setVoiceVisionModel] = useState("gpt-4.1-mini");
710
+ const [voiceId, setVoiceId] = useState("alloy");
711
+ const [voiceTurnDetection, setVoiceTurnDetection] = useState("server_vad");
712
+ const [voiceFallbackMode, setVoiceFallbackMode] = useState("browser");
713
+ const [voiceDelegateExecutor, setVoiceDelegateExecutor] = useState("codex-sdk");
714
+ const [openaiRealtimeApiKey, setOpenaiRealtimeApiKey] = useState("");
715
+ const [azureOpenaiRealtimeEndpoint, setAzureOpenaiRealtimeEndpoint] = useState("");
716
+ const [azureOpenaiRealtimeApiKey, setAzureOpenaiRealtimeApiKey] = useState("");
717
+ const [azureOpenaiRealtimeDeployment, setAzureOpenaiRealtimeDeployment] = useState("gpt-4o-realtime-preview");
682
718
 
683
719
  const getWorkflowProfileById = (profileId, profileList = workflowProfiles) =>
684
720
  (profileList || []).find((profile_) => profile_.id === profileId) || null;
@@ -866,6 +902,18 @@ function App() {
866
902
  }
867
903
  if (d.bosunHome) setBosunHome(d.bosunHome);
868
904
  if (d.workspacesDir) setWorkspacesDir(d.workspacesDir);
905
+ if (d.voiceEnabled !== undefined) { setVoiceEnabled(d.voiceEnabled !== false); }
906
+ if (d.voiceProvider) { setVoiceProvider(d.voiceProvider); }
907
+ if (d.voiceModel) { setVoiceModel(d.voiceModel); }
908
+ if (d.voiceVisionModel) { setVoiceVisionModel(d.voiceVisionModel); }
909
+ if (d.voiceId) { setVoiceId(d.voiceId); }
910
+ if (d.voiceTurnDetection) { setVoiceTurnDetection(d.voiceTurnDetection); }
911
+ if (d.voiceFallbackMode) { setVoiceFallbackMode(d.voiceFallbackMode); }
912
+ if (d.voiceDelegateExecutor) { setVoiceDelegateExecutor(d.voiceDelegateExecutor); }
913
+ if (d.openaiRealtimeApiKey) { setOpenaiRealtimeApiKey(d.openaiRealtimeApiKey); }
914
+ if (d.azureOpenaiRealtimeEndpoint) { setAzureOpenaiRealtimeEndpoint(d.azureOpenaiRealtimeEndpoint); }
915
+ if (d.azureOpenaiRealtimeApiKey) { setAzureOpenaiRealtimeApiKey(d.azureOpenaiRealtimeApiKey); }
916
+ if (d.azureOpenaiRealtimeDeployment) { setAzureOpenaiRealtimeDeployment(d.azureOpenaiRealtimeDeployment); }
869
917
 
870
918
  // Pre-fill repos from existing config (slugs preferred)
871
919
  if (statusData.existingConfig?.repos?.length) {
@@ -877,6 +925,19 @@ function App() {
877
925
  // ── Pre-fill from existing .env ──────────────────────────────────────
878
926
  const env = statusData.existingEnv || {};
879
927
  let envLoaded = false;
928
+ const existingVoice = statusData.existingConfig?.voice || {};
929
+ if (existingVoice.enabled != null) { setVoiceEnabled(existingVoice.enabled !== false); envLoaded = true; }
930
+ if (existingVoice.provider) { setVoiceProvider(String(existingVoice.provider)); envLoaded = true; }
931
+ if (existingVoice.model) { setVoiceModel(String(existingVoice.model)); envLoaded = true; }
932
+ if (existingVoice.visionModel) { setVoiceVisionModel(String(existingVoice.visionModel)); envLoaded = true; }
933
+ if (existingVoice.voiceId) { setVoiceId(String(existingVoice.voiceId)); envLoaded = true; }
934
+ if (existingVoice.turnDetection) { setVoiceTurnDetection(String(existingVoice.turnDetection)); envLoaded = true; }
935
+ if (existingVoice.fallbackMode) { setVoiceFallbackMode(String(existingVoice.fallbackMode)); envLoaded = true; }
936
+ if (existingVoice.delegateExecutor) { setVoiceDelegateExecutor(String(existingVoice.delegateExecutor)); envLoaded = true; }
937
+ if (existingVoice.openaiApiKey) { setOpenaiRealtimeApiKey(String(existingVoice.openaiApiKey)); envLoaded = true; }
938
+ if (existingVoice.azureEndpoint) { setAzureOpenaiRealtimeEndpoint(String(existingVoice.azureEndpoint)); envLoaded = true; }
939
+ if (existingVoice.azureApiKey) { setAzureOpenaiRealtimeApiKey(String(existingVoice.azureApiKey)); envLoaded = true; }
940
+ if (existingVoice.azureDeployment) { setAzureOpenaiRealtimeDeployment(String(existingVoice.azureDeployment)); envLoaded = true; }
880
941
  if (env.BOSUN_HOME) { setBosunHome(env.BOSUN_HOME); envLoaded = true; }
881
942
  if (env.BOSUN_WORKSPACES_DIR) { setWorkspacesDir(env.BOSUN_WORKSPACES_DIR); setWorkspacesDirCustomized(true); envLoaded = true; }
882
943
  if (env.PROJECT_NAME) { setProjectName(env.PROJECT_NAME); envLoaded = true; }
@@ -981,6 +1042,19 @@ function App() {
981
1042
  if (env.WHATSAPP_ENABLED) { setWhatsappEnabled(env.WHATSAPP_ENABLED === "true"); envLoaded = true; }
982
1043
  if (env.TELEGRAM_INTERVAL_MIN) { setTelegramIntervalMin(Number(env.TELEGRAM_INTERVAL_MIN) || 10); envLoaded = true; }
983
1044
  if (env.ORCHESTRATOR_SCRIPT) { setOrchestratorScript(env.ORCHESTRATOR_SCRIPT); envLoaded = true; }
1045
+ // Voice settings
1046
+ if (env.VOICE_ENABLED !== undefined) { setVoiceEnabled(env.VOICE_ENABLED !== "false"); envLoaded = true; }
1047
+ if (env.VOICE_PROVIDER) { setVoiceProvider(env.VOICE_PROVIDER); envLoaded = true; }
1048
+ if (env.VOICE_MODEL) { setVoiceModel(env.VOICE_MODEL); envLoaded = true; }
1049
+ if (env.VOICE_VISION_MODEL) { setVoiceVisionModel(env.VOICE_VISION_MODEL); envLoaded = true; }
1050
+ if (env.VOICE_ID) { setVoiceId(env.VOICE_ID); envLoaded = true; }
1051
+ if (env.VOICE_TURN_DETECTION) { setVoiceTurnDetection(env.VOICE_TURN_DETECTION); envLoaded = true; }
1052
+ if (env.VOICE_FALLBACK_MODE) { setVoiceFallbackMode(env.VOICE_FALLBACK_MODE); envLoaded = true; }
1053
+ if (env.VOICE_DELEGATE_EXECUTOR) { setVoiceDelegateExecutor(env.VOICE_DELEGATE_EXECUTOR); envLoaded = true; }
1054
+ if (env.OPENAI_REALTIME_API_KEY) { setOpenaiRealtimeApiKey(env.OPENAI_REALTIME_API_KEY); envLoaded = true; }
1055
+ if (env.AZURE_OPENAI_REALTIME_ENDPOINT) { setAzureOpenaiRealtimeEndpoint(env.AZURE_OPENAI_REALTIME_ENDPOINT); envLoaded = true; }
1056
+ if (env.AZURE_OPENAI_REALTIME_API_KEY) { setAzureOpenaiRealtimeApiKey(env.AZURE_OPENAI_REALTIME_API_KEY); envLoaded = true; }
1057
+ if (env.AZURE_OPENAI_REALTIME_DEPLOYMENT) { setAzureOpenaiRealtimeDeployment(env.AZURE_OPENAI_REALTIME_DEPLOYMENT); envLoaded = true; }
984
1058
  // Multi-workspace: load workspaces[] from existing config
985
1059
  if (statusData.existingConfig?.workspaces?.length > 0) {
986
1060
  setMultiWorkspaceEnabled(true);
@@ -1235,6 +1309,19 @@ function App() {
1235
1309
  copilotEnableAskUser,
1236
1310
  copilotEnableAllMcpTools,
1237
1311
  copilotMcpConfig,
1312
+ // Voice assistant
1313
+ voiceEnabled,
1314
+ voiceProvider,
1315
+ voiceModel,
1316
+ voiceVisionModel,
1317
+ voiceId,
1318
+ voiceTurnDetection,
1319
+ voiceFallbackMode,
1320
+ voiceDelegateExecutor,
1321
+ openaiRealtimeApiKey,
1322
+ azureOpenaiRealtimeEndpoint,
1323
+ azureOpenaiRealtimeApiKey,
1324
+ azureOpenaiRealtimeDeployment,
1238
1325
  // Infrastructure
1239
1326
  containerEnabled,
1240
1327
  containerRuntime,
@@ -1531,7 +1618,7 @@ function App() {
1531
1618
  <div class="setup-container">
1532
1619
  <div class="step-panel">
1533
1620
  <div class="success-banner">
1534
- <div class="icon">:star:</div>
1621
+ <div class="icon">${iconText(":star:")}</div>
1535
1622
  <h2>Setup Complete!</h2>
1536
1623
  <p>Bosun is configured and ready to go.</p>
1537
1624
  <p style="margin-top:12px;font-size:0.8rem;color:var(--text-dim)">
@@ -1596,7 +1683,7 @@ function App() {
1596
1683
  const icon = item.installed ? ":check:" : item.required ? ":close:" : ":alert:";
1597
1684
  return html`
1598
1685
  <li class="prereq-item ${statusClass}">
1599
- <span class="icon">${icon}</span>
1686
+ <span class="icon">${iconText(icon)}</span>
1600
1687
  <span class="name">${item.label}${!item.required ? " (optional)" : ""}</span>
1601
1688
  <span class="version">${item.version || (item.installed ? "found" : "not found")}</span>
1602
1689
  </li>
@@ -1604,7 +1691,7 @@ function App() {
1604
1691
  })}
1605
1692
  ${prereqs.gh && !prereqs.gh.authenticated && prereqs.gh.installed ? html`
1606
1693
  <li class="prereq-item warn">
1607
- <span class="icon">:alert:</span>
1694
+ <span class="icon">${iconText(":alert:")}</span>
1608
1695
  <span class="name">GitHub CLI not authenticated</span>
1609
1696
  <span class="version">Run: gh auth login</span>
1610
1697
  </li>
@@ -1655,12 +1742,12 @@ function App() {
1655
1742
 
1656
1743
  <div class="profile-cards">
1657
1744
  <div class="profile-card ${profile === "standard" ? "selected" : ""}" onclick=${() => setProfile("standard")}>
1658
- <div class="icon">:zap:</div>
1745
+ <div class="icon">${iconText(":zap:")}</div>
1659
1746
  <h4>Standard</h4>
1660
1747
  <p>Sensible defaults with primary & backup executors. Best for most users.</p>
1661
1748
  </div>
1662
1749
  <div class="profile-card ${profile === "advanced" ? "selected" : ""}" onclick=${() => setProfile("advanced")}>
1663
- <div class="icon">:settings:</div>
1750
+ <div class="icon">${iconText(":settings:")}</div>
1664
1751
  <h4>Advanced</h4>
1665
1752
  <p>Full control over executors, failover, distribution weights, and all settings.</p>
1666
1753
  </div>
@@ -1690,7 +1777,7 @@ function App() {
1690
1777
  <p class="step-desc">Configure which AI coding agents bosun will use. You can add multiple executors with weighted distribution.</p>
1691
1778
 
1692
1779
  <div style="background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.25);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:0.8rem;line-height:1.6">
1693
- <strong>:lock: No API key required in most cases.</strong>
1780
+ <strong>${iconText(":lock: No API key required in most cases.")}</strong>
1694
1781
  GitHub Copilot, Codex CLI, and Claude Code all support OAuth — just run
1695
1782
  <code style="font-family:var(--font-mono);color:var(--accent-light)">gh auth login</code>,
1696
1783
  <code style="font-family:var(--font-mono);color:var(--accent-light)">codex auth login</code>, or
@@ -1710,10 +1797,11 @@ function App() {
1710
1797
  ? configuredModelOptions
1711
1798
  : getModelsForExecutor(ex.executor);
1712
1799
  const authMode = ex.authMode || "oauth";
1800
+ const executorHeading = `${ex.enabled === false ? ":dot:" : ex.role === "primary" ? ":dot:" : ":dot:"} Executor ${i + 1}: ${ex.name}`;
1713
1801
  return html`
1714
1802
  <div class="executor-card">
1715
1803
  <div class="executor-card-header">
1716
- <h4>${ex.enabled === false ? ":dot:" : ex.role === "primary" ? ":dot:" : ":dot:"} Executor ${i + 1}: ${ex.name}</h4>
1804
+ <h4>${iconText(executorHeading)}</h4>
1717
1805
  ${executors.length > 1 && html`
1718
1806
  <button class="btn btn-sm btn-danger" onclick=${() => removeExecutor(i)}>Remove</button>
1719
1807
  `}
@@ -1798,7 +1886,7 @@ function App() {
1798
1886
  return html`
1799
1887
  <div class="connection-card">
1800
1888
  <div class="connection-card-header">
1801
- <span class="connection-label">${isPrimary ? ":star: Primary" : `:globe: Profile ${ci + 1}`}: ${conn.name || (isPrimary ? "default" : "(unnamed)")}</span>
1889
+ <span class="connection-label">${iconText(`${isPrimary ? ":star: Primary" : `:globe: Profile ${ci + 1}`}: ${conn.name || (isPrimary ? "default" : "(unnamed)")}`)}</span>
1802
1890
  <button class="btn btn-sm btn-danger"
1803
1891
  onclick=${() => removeConnection(i, ci)}>✕ Remove</button>
1804
1892
  </div>
@@ -1945,7 +2033,7 @@ function App() {
1945
2033
 
1946
2034
  ${!multiWorkspaceEnabled && html`
1947
2035
  <div style="background:var(--bg-input);border:1px solid var(--border-primary);border-radius:var(--radius-sm);padding:10px 14px;margin-bottom:16px;font-size:0.78rem;color:var(--text-secondary)">
1948
- :folder: Repos will be cloned into:
2036
+ ${iconText(":folder: Repos will be cloned into:")}
1949
2037
  <code style="font-family:var(--font-mono);color:var(--accent-light);margin-left:4px">${cloneRoot}/${"<repo-name>"}</code>
1950
2038
  </div>
1951
2039
  `}
@@ -2211,12 +2299,12 @@ function App() {
2211
2299
  ${/* ── GitHub App installation callout ── */ ""}
2212
2300
  <div style="background:rgba(56,139,253,.07);border:1px solid rgba(56,139,253,.25);border-radius:var(--radius-sm);padding:14px 16px;margin-bottom:20px">
2213
2301
  <div style="display:flex;align-items:flex-start;gap:10px">
2214
- <span style="font-size:1.3em;line-height:1.2;flex-shrink:0">:box:</span>
2302
+ <span style="font-size:1.3em;line-height:1.2;flex-shrink:0">${iconText(":box:")}</span>
2215
2303
  <div style="flex:1;min-width:0">
2216
2304
  <div style="font-weight:600;font-size:0.9rem;color:var(--accent-light);margin-bottom:4px">GitHub App — Bosun[VE]</div>
2217
2305
  ${oauthStatus === "received"
2218
2306
  ? html`<div style="color:#4ade80;font-size:0.82rem">
2219
- :check: GitHub App authorized!
2307
+ ${iconText(":check: GitHub App authorized!")}
2220
2308
  ${oauthInstallationId ? html` (Installation <code style="font-family:var(--font-mono)">${oauthInstallationId}</code>)` : null}
2221
2309
  </div>`
2222
2310
  : html`<div style="font-size:0.82rem;color:var(--text-secondary);line-height:1.5">
@@ -2226,7 +2314,7 @@ function App() {
2226
2314
  <div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2227
2315
  <a href="https://github.com/apps/bosun-ve" target="_blank" rel="noopener"
2228
2316
  style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--accent);color:#fff;border-radius:5px;font-size:0.8rem;font-weight:600;text-decoration:none">
2229
- :link: Install from GitHub Marketplace
2317
+ ${iconText(":link: Install from GitHub Marketplace")}
2230
2318
  </a>
2231
2319
  <span style="font-size:0.75rem;color:var(--text-dim)">
2232
2320
  <span class="spinner" style="width:10px;height:10px;border-width:2px;vertical-align:middle;margin-right:4px"></span>
@@ -2278,7 +2366,7 @@ function App() {
2278
2366
  <div style="border:1px solid var(--border-primary);border-radius:var(--radius-sm);margin-bottom:10px;overflow:hidden">
2279
2367
  <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;background:var(--bg-input);user-select:none"
2280
2368
  onclick=${() => setOpen((v) => !v)}>
2281
- <span style="font-weight:600;font-size:0.88rem">${title}</span>
2369
+ <span style="font-weight:600;font-size:0.88rem">${iconText(title)}</span>
2282
2370
  <span style="color:var(--text-dim);font-size:0.8rem">${open ? "▲" : "▼"}</span>
2283
2371
  </div>
2284
2372
  ${open && html`<div style="padding:16px">${children}</div>`}
@@ -2594,6 +2682,135 @@ function App() {
2594
2682
  </div>
2595
2683
  <//>
2596
2684
 
2685
+ <${Section} title=":mic: Voice Assistant">
2686
+ <div class="form-group">
2687
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
2688
+ <input type="checkbox" checked=${voiceEnabled}
2689
+ onchange=${(e) => setVoiceEnabled(e.target.checked)}
2690
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
2691
+ Enable Voice Mode in the UI
2692
+ </label>
2693
+ <div class="hint">Allows live voice/video calls from chat. Tier 2 browser fallback works without cloud keys.</div>
2694
+ </div>
2695
+ ${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
+ />
2741
+ </div>
2742
+ <div class="form-group">
2743
+ <label>Turn Detection</label>
2744
+ <select value=${voiceTurnDetection} onchange=${(e) => setVoiceTurnDetection(e.target.value)}>
2745
+ <option value="server_vad">server_vad</option>
2746
+ <option value="semantic_vad">semantic_vad</option>
2747
+ <option value="none">none</option>
2748
+ </select>
2749
+ </div>
2750
+ <div class="form-group">
2751
+ <label>Fallback Mode</label>
2752
+ <select value=${voiceFallbackMode} onchange=${(e) => setVoiceFallbackMode(e.target.value)}>
2753
+ <option value="browser">browser</option>
2754
+ <option value="disabled">disabled</option>
2755
+ </select>
2756
+ </div>
2757
+ <div class="form-group">
2758
+ <label>Delegate Executor</label>
2759
+ <select value=${voiceDelegateExecutor} onchange=${(e) => setVoiceDelegateExecutor(e.target.value)}>
2760
+ <option value="codex-sdk">codex-sdk</option>
2761
+ <option value="copilot-sdk">copilot-sdk</option>
2762
+ <option value="claude-sdk">claude-sdk</option>
2763
+ <option value="gemini-sdk">gemini-sdk</option>
2764
+ <option value="opencode-sdk">opencode-sdk</option>
2765
+ </select>
2766
+ </div>
2767
+ </div>
2768
+ ${(voiceProvider === "auto" || voiceProvider === "openai") && html`
2769
+ <div class="form-group">
2770
+ <label>OpenAI Realtime API Key</label>
2771
+ <input
2772
+ type="password"
2773
+ value=${openaiRealtimeApiKey}
2774
+ oninput=${(e) => setOpenaiRealtimeApiKey(e.target.value)}
2775
+ placeholder="Optional - defaults to OPENAI_API_KEY"
2776
+ />
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
+ </div>
2801
+ <div class="form-group">
2802
+ <label>Azure Realtime API Key</label>
2803
+ <input
2804
+ type="password"
2805
+ value=${azureOpenaiRealtimeApiKey}
2806
+ oninput=${(e) => setAzureOpenaiRealtimeApiKey(e.target.value)}
2807
+ placeholder="Optional - defaults to AZURE_OPENAI_API_KEY"
2808
+ />
2809
+ </div>
2810
+ `}
2811
+ `}
2812
+ <//>
2813
+
2597
2814
  <${Section} title=":hammer: Infrastructure">
2598
2815
  <div class="form-group">
2599
2816
  <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
@@ -2698,6 +2915,14 @@ function App() {
2698
2915
  </tr>
2699
2916
  <tr><th>Workflow Auto-Install</th><td>${workflowAutoInstall ? "Enabled" : "Disabled"}</td></tr>
2700
2917
  <tr><th>Telegram</th><td>${telegramEnabled && telegramToken ? "Configured" : "Skipped"}</td></tr>
2918
+ <tr>
2919
+ <th>Voice</th>
2920
+ <td>
2921
+ ${voiceEnabled
2922
+ ? `${voiceProvider} (${voiceFallbackMode} fallback)`
2923
+ : "Disabled"}
2924
+ </td>
2925
+ </tr>
2701
2926
  ${profile === "advanced" ? html`
2702
2927
  <tr><th>Max Parallel</th><td>${maxParallel}</td></tr>
2703
2928
  <tr><th>Max Retries</th><td>${maxRetries}</td></tr>
@@ -2765,7 +2990,7 @@ function App() {
2765
2990
  `}
2766
2991
  ${oauthStatus === "received" && html`
2767
2992
  <div style="background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.35);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:0.85rem;color:#4ade80;display:flex;align-items:flex-start;gap:10px">
2768
- <span style="font-size:1.2em;line-height:1">:check:</span>
2993
+ <span style="font-size:1.2em;line-height:1">${iconText(":check:")}</span>
2769
2994
  <div>
2770
2995
  <strong>GitHub App authorized!</strong>
2771
2996
  ${oauthInstallationId && html` Installation ID: <code style="font-family:var(--font-mono);font-size:0.8em;color:#86efac">${oauthInstallationId}</code>.`}
@@ -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…");
@@ -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
  `)}
@@ -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
package/ui-server.mjs CHANGED
@@ -9022,6 +9022,7 @@ async function handleApi(req, res, url) {
9022
9022
  available: availability.available,
9023
9023
  tier: availability.tier,
9024
9024
  provider: availability.provider,
9025
+ reason: availability.reason || "",
9025
9026
  voiceId: config.voiceId,
9026
9027
  turnDetection: config.turnDetection,
9027
9028
  model: config.model,