bosun 0.30.0 → 0.31.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.
@@ -42,6 +42,7 @@ function ensureElectronInstalled() {
42
42
  const result = spawnSync("npm", ["install"], {
43
43
  cwd: desktopDir,
44
44
  stdio: "inherit",
45
+ shell: process.platform === "win32",
45
46
  env: process.env,
46
47
  });
47
48
  return result.status === 0 && existsSync(electronBin);
@@ -60,6 +61,7 @@ function launch() {
60
61
 
61
62
  const child = spawn(electronBin, args, {
62
63
  stdio: "inherit",
64
+ shell: process.platform === "win32",
63
65
  env: {
64
66
  ...process.env,
65
67
  BOSUN_DESKTOP: "1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.30.0",
3
+ "version": "0.31.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",
@@ -28,21 +28,43 @@ const MAX_PORT_ATTEMPTS = 20;
28
28
 
29
29
  const MODELS = {
30
30
  copilot: [
31
- { value: "claude-sonnet-4", label: "Claude Sonnet 4 (recommended)", recommended: true },
32
- { value: "claude-opus-4", label: "Claude Opus 4" },
33
- { value: "gpt-4.1", label: "GPT-4.1" },
34
- { value: "o3", label: "o3" },
35
- { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
31
+ { value: "claude-opus-4.6", label: "claude-opus-4.6", recommended: true },
32
+ { value: "claude-sonnet-4.6", label: "claude-sonnet-4.6" },
33
+ { value: "claude-opus-4.5", label: "claude-opus-4.5" },
34
+ { value: "claude-sonnet-4.5", label: "claude-sonnet-4.5" },
35
+ { value: "claude-sonnet-4", label: "claude-sonnet-4" },
36
+ { value: "claude-haiku-4.5", label: "claude-haiku-4.5" },
37
+ { value: "gpt-5.2-codex", label: "gpt-5.2-codex" },
38
+ { value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
39
+ { value: "gpt-5.1-codex", label: "gpt-5.1-codex" },
40
+ { value: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini" },
41
+ { value: "gpt-5.1-codex-max", label: "gpt-5.1-codex-max" },
42
+ { value: "gpt-5.2", label: "gpt-5.2" },
43
+ { value: "gpt-5.1", label: "gpt-5.1" },
44
+ { value: "gpt-5-mini", label: "gpt-5-mini" },
45
+ { value: "gemini-3.1-pro", label: "gemini-3.1-pro" },
46
+ { value: "gemini-3-pro", label: "gemini-3-pro" },
47
+ { value: "gemini-3-flash", label: "gemini-3-flash" },
48
+ { value: "gemini-2.5-pro", label: "gemini-2.5-pro" },
49
+ { value: "grok-code-fast-1", label: "grok-code-fast-1" },
36
50
  ],
37
51
  codex: [
38
- { value: "o3", label: "o3 (recommended)", recommended: true },
39
- { value: "o4-mini", label: "o4-mini" },
40
- { value: "gpt-4.1", label: "gpt-4.1" },
41
- { value: "codex-mini-latest", label: "codex-mini-latest" },
52
+ { value: "gpt-5.3-codex", label: "gpt-5.3-codex", recommended: true },
53
+ { value: "gpt-5.2-codex", label: "gpt-5.2-codex" },
54
+ { value: "gpt-5.1-codex", label: "gpt-5.1-codex" },
55
+ { value: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini" },
56
+ { value: "gpt-5.1-codex-max", label: "gpt-5.1-codex-max" },
57
+ { value: "gpt-5.2", label: "gpt-5.2" },
58
+ { value: "gpt-5.1", label: "gpt-5.1" },
59
+ { value: "gpt-5-mini", label: "gpt-5-mini" },
42
60
  ],
43
61
  claude: [
44
- { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4 (recommended)", recommended: true },
45
- { value: "claude-opus-4-20250514", label: "Claude Opus 4" },
62
+ { value: "claude-opus-4.6", label: "claude-opus-4.6", recommended: true },
63
+ { value: "claude-sonnet-4.6", label: "claude-sonnet-4.6" },
64
+ { value: "claude-opus-4.5", label: "claude-opus-4.5" },
65
+ { value: "claude-sonnet-4.5", label: "claude-sonnet-4.5" },
66
+ { value: "claude-sonnet-4", label: "claude-sonnet-4" },
67
+ { value: "claude-haiku-4.5", label: "claude-haiku-4.5" },
46
68
  ],
47
69
  };
48
70
 
@@ -361,6 +361,36 @@ const AGENT_SELECTOR_STYLES = `
361
361
  padding: 8px 12px;
362
362
  }
363
363
  }
364
+
365
+ /* ── Native Agent Select ── */
366
+ .agent-picker-native {
367
+ appearance: none;
368
+ -webkit-appearance: none;
369
+ background: var(--tg-theme-secondary-bg-color, #1e1e2e);
370
+ border: 1px solid rgba(255,255,255,0.08);
371
+ border-radius: 8px;
372
+ color: var(--tg-theme-text-color, #fff);
373
+ font-size: 12px;
374
+ font-weight: 500;
375
+ padding: 5px 28px 5px 28px;
376
+ cursor: pointer;
377
+ min-width: 120px;
378
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M1 3l4 4 4-4'/%3E%3C/svg%3E");
379
+ background-repeat: no-repeat;
380
+ background-position: right 8px center;
381
+ transition: border-color 0.2s ease;
382
+ }
383
+ .agent-picker-native:hover {
384
+ border-color: rgba(255,255,255,0.15);
385
+ }
386
+ .agent-picker-native:focus {
387
+ outline: none;
388
+ border-color: var(--tg-theme-button-color, #3b82f6);
389
+ }
390
+ .agent-picker-native option {
391
+ background: #1a1a2e;
392
+ color: #fff;
393
+ }
364
394
  `;
365
395
 
366
396
  let _agentStylesInjected = false;
@@ -480,106 +510,41 @@ export function AgentModeSelector() {
480
510
  * ═══════════════════════════════════════════════ */
481
511
 
482
512
  export function AgentPicker() {
483
- const [open, setOpen] = useState(false);
484
- const wrapRef = useRef(null);
485
513
  const agents = availableAgents.value;
486
- const current = activeAgentInfo.value;
514
+ const current = activeAgent.value;
487
515
  const loading = agentSelectorLoading.value;
488
516
 
489
- // Close on outside click
490
- useEffect(() => {
491
- if (!open) return;
492
- const handler = (e) => {
493
- if (wrapRef.current && !wrapRef.current.contains(e.target)) {
494
- setOpen(false);
495
- }
496
- };
497
- document.addEventListener("pointerdown", handler, true);
498
- return () => document.removeEventListener("pointerdown", handler, true);
499
- }, [open]);
500
-
501
- // Close on Escape
502
- useEffect(() => {
503
- if (!open) return;
504
- const handler = (e) => { if (e.key === "Escape") setOpen(false); };
505
- document.addEventListener("keydown", handler);
506
- return () => document.removeEventListener("keydown", handler);
507
- }, [open]);
508
-
509
- const handleToggle = useCallback(() => {
510
- haptic("light");
511
- setOpen((v) => !v);
512
- }, []);
513
-
514
- const handleSelect = useCallback((agentId) => {
515
- if (agentId === activeAgent.value) {
516
- setOpen(false);
517
- return;
518
- }
517
+ const handleChange = useCallback((e) => {
518
+ const agentId = e.target.value;
519
+ if (agentId === activeAgent.value) return;
519
520
  haptic("medium");
520
521
  switchAgent(agentId);
521
- setOpen(false);
522
522
  }, []);
523
523
 
524
- const currentIcon = current ? (AGENT_ICONS[current.id] || "🔌") : "🔌";
525
- const currentName = current ? current.name : (loading ? "Loading…" : "Select Agent");
524
+ const currentIcon = AGENT_ICONS[current] || "";
526
525
 
527
526
  return html`
528
- <div class="agent-picker-wrap" ref=${wrapRef}>
529
- <button
530
- class="agent-picker-btn ${open ? "open" : ""}"
531
- onClick=${handleToggle}
532
- aria-haspopup="listbox"
533
- aria-expanded=${open}
527
+ <div class="agent-picker-wrap">
528
+ <span class="picker-icon" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);font-size:13px;pointer-events:none;z-index:1">${currentIcon}</span>
529
+ <select
530
+ class="agent-picker-native"
531
+ value=${current}
532
+ onChange=${handleChange}
533
+ disabled=${loading}
534
534
  title="Select AI agent"
535
535
  >
536
- <span class="picker-icon">${currentIcon}</span>
537
- <span>${currentName}</span>
538
- <span class="agent-picker-chevron">▾</span>
539
- </button>
540
-
541
- ${open && html`
542
- <div class="agent-picker-backdrop" onClick=${() => setOpen(false)} />
543
- <div class="agent-picker-dropdown" role="listbox" aria-label="Available agents">
544
- ${agents.length === 0 && html`
545
- <div style="padding: 12px; text-align: center; color: var(--tg-theme-hint-color, #888); font-size: 12px;">
546
- ${loading ? "Loading agents…" : "No agents available"}
547
- </div>
548
- `}
549
- ${agents.map((agent) => {
550
- const isActive = agent.id === activeAgent.value;
551
- const icon = AGENT_ICONS[agent.id] || "🔌";
552
- const statusClass = agent.busy ? "busy" : agent.available ? "available" : "offline";
553
- const providerColor = PROVIDER_COLORS[agent.provider] || "#6b7280";
554
-
555
- return html`
556
- <button
557
- key=${agent.id}
558
- class="agent-picker-item ${isActive ? "active" : ""}"
559
- role="option"
560
- aria-selected=${isActive}
561
- onClick=${() => handleSelect(agent.id)}
562
- >
563
- <div class="agent-picker-item-icon">${icon}</div>
564
- <div class="agent-picker-item-info">
565
- <div class="agent-picker-item-name">${agent.name}</div>
566
- <div class="agent-picker-item-provider">
567
- <span
568
- class="agent-provider-badge"
569
- style="background: ${providerColor}"
570
- >${agent.provider}</span>
571
- </div>
572
- </div>
573
- <div class="agent-picker-item-end">
574
- <span class="agent-picker-status-dot ${statusClass}"
575
- title=${statusClass} />
576
- ${isActive && html`<span class="agent-picker-check">✓</span>`}
577
- </div>
578
- </button>
579
- `;
580
- })}
581
- </div>
582
- `}
536
+ ${agents.length === 0 && html`
537
+ <option disabled>${loading ? "Loading…" : "No agents"}</option>
538
+ `}
539
+ ${agents.map((agent) => {
540
+ const statusLabel = agent.busy ? " (busy)" : !agent.available ? " (offline)" : "";
541
+ return html`
542
+ <option key=${agent.id} value=${agent.id}>
543
+ ${agent.name} · ${agent.provider}${statusLabel}
544
+ </option>
545
+ `;
546
+ })}
547
+ </select>
583
548
  </div>
584
549
  `;
585
550
  }
@@ -268,6 +268,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
268
268
  const [autoScroll, setAutoScroll] = useState(true);
269
269
  const [unreadCount, setUnreadCount] = useState(0);
270
270
  const [visibleCount, setVisibleCount] = useState(200);
271
+ const [showStreamMeta, setShowStreamMeta] = useState(false);
271
272
  const [filters, setFilters] = useState({
272
273
  tool: false,
273
274
  result: false,
@@ -704,15 +705,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
704
705
  >
705
706
  ${paused ? "Resume" : "Pause"}
706
707
  </button>
707
- <button class="btn btn-ghost btn-xs" onClick=${handleCopyStream} title="Copy stream">
708
- Copy
709
- </button>
710
- <button class="btn btn-ghost btn-xs" onClick=${handleExportStream} title="Export stream">
711
- Export
708
+ <button class="btn btn-ghost btn-xs" onClick=${() => setShowStreamMeta((prev) => !prev)} title="Toggle filters">
709
+ ${showStreamMeta ? "Hide filters" : "Filters"}
712
710
  </button>
713
711
  </div>
714
712
  </div>
715
- <div class="chat-stream-meta">
713
+ <div class="chat-stream-meta ${showStreamMeta ? 'expanded' : ''}">
716
714
  <div class="chat-stream-filters">
717
715
  <button
718
716
  class="chat-filter-chip ${activeFilters.length === 0 ? "active" : ""}"
@@ -4,6 +4,7 @@
4
4
  * ────────────────────────────────────────────────────────────── */
5
5
 
6
6
  import { h } from "preact";
7
+ import { createPortal } from "preact/compat";
7
8
  import {
8
9
  useState,
9
10
  useEffect,
@@ -319,7 +320,7 @@ export function Modal({ title, open = true, onClose, children, contentClassName
319
320
  ? `transform: translateY(${dragY}px); opacity: ${Math.max(0.2, 1 - dragY / 400)}`
320
321
  : "";
321
322
 
322
- return html`
323
+ const content = html`
323
324
  <div
324
325
  class="modal-overlay ${visible ? "modal-overlay-visible" : ""}"
325
326
  onClick=${(e) => {
@@ -354,6 +355,7 @@ export function Modal({ title, open = true, onClose, children, contentClassName
354
355
  </div>
355
356
  </div>
356
357
  `;
358
+ return createPortal(content, document.body);
357
359
  }
358
360
 
359
361
  /* ═══════════════════════════════════════════════
package/ui/setup.html CHANGED
@@ -410,6 +410,7 @@ function SelectWithCustom({ options, value, onChange, placeholder }) {
410
410
  const handleSelect = (e) => {
411
411
  if (e.target.value === "__custom__") {
412
412
  setIsCustom(true);
413
+ setCustomValue(value || "");
413
414
  setTimeout(() => inputRef.current?.focus(), 50);
414
415
  } else {
415
416
  setIsCustom(false);
@@ -418,9 +419,12 @@ function SelectWithCustom({ options, value, onChange, placeholder }) {
418
419
  }
419
420
  };
420
421
 
421
- const handleCustomChange = (e) => {
422
- setCustomValue(e.target.value);
423
- onChange(e.target.value);
422
+ const commitCustom = () => {
423
+ if (customValue.trim()) onChange(customValue.trim());
424
+ };
425
+
426
+ const handleCustomKeyDown = (e) => {
427
+ if (e.key === "Enter") { commitCustom(); e.target.blur(); }
424
428
  };
425
429
 
426
430
  return html`
@@ -435,8 +439,10 @@ function SelectWithCustom({ options, value, onChange, placeholder }) {
435
439
  ref=${inputRef}
436
440
  type="text"
437
441
  value=${customValue}
438
- oninput=${handleCustomChange}
439
- placeholder="Enter custom value..."
442
+ oninput=${(e) => setCustomValue(e.target.value)}
443
+ onblur=${commitCustom}
444
+ onkeydown=${handleCustomKeyDown}
445
+ placeholder="Enter model slug..."
440
446
  />
441
447
  <button class="btn btn-sm" onclick=${() => { setIsCustom(false); onChange(options[0]?.value || ""); }}>
442
448
  Cancel
@@ -469,8 +475,8 @@ function App() {
469
475
  const [repoSlug, setRepoSlug] = useState("");
470
476
  const [repoRoot, setRepoRoot] = useState("");
471
477
  const [executors, setExecutors] = useState([
472
- { name: "primary", executor: "COPILOT", model: "claude-sonnet-4", weight: 70, role: "primary" },
473
- { name: "backup", executor: "CODEX", model: "o3", weight: 30, role: "backup" },
478
+ { name: "primary", executor: "COPILOT", model: "claude-opus-4.6", weight: 70, role: "primary" },
479
+ { name: "backup", executor: "CODEX", model: "gpt-5.2-codex", weight: 30, role: "backup" },
474
480
  ]);
475
481
  const [repos, setRepos] = useState([""]);
476
482
  const [kanbanBackend, setKanbanBackend] = useState("internal");
@@ -845,7 +845,7 @@ select.input {
845
845
  background: var(--backdrop);
846
846
  backdrop-filter: var(--backdrop-blur);
847
847
  -webkit-backdrop-filter: var(--backdrop-blur);
848
- z-index: 9500;
848
+ z-index: 10000;
849
849
  display: flex;
850
850
  align-items: flex-end;
851
851
  justify-content: center;
@@ -882,6 +882,21 @@ select.input {
882
882
  max-width: 860px;
883
883
  }
884
884
 
885
+ /* ── Desktop: Center modal as dialog ── */
886
+ @media (min-width: 769px) {
887
+ .modal-overlay {
888
+ align-items: center;
889
+ padding: 24px;
890
+ z-index: 10000;
891
+ }
892
+ .modal-content {
893
+ border-radius: var(--radius-xl);
894
+ border-bottom: 1px solid var(--border);
895
+ max-height: 85vh;
896
+ animation: springUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
897
+ }
898
+ }
899
+
885
900
  .modal-form-grid {
886
901
  display: flex;
887
902
  flex-direction: column;
@@ -66,19 +66,7 @@
66
66
  }
67
67
  }
68
68
 
69
- @media (min-width: 1200px) {
70
- .app-shell[data-has-rail="true"] .session-split {
71
- grid-template-columns: 1fr;
72
- }
73
-
74
- .app-shell[data-has-rail="true"] .session-pane {
75
- display: none;
76
- }
77
-
78
- .app-shell[data-has-rail="true"] .chat-shell-header .session-drawer-btn {
79
- display: none;
80
- }
81
- }
69
+ /* Rail no longer hides the session pane on desktop — sessions always visible */
82
70
 
83
71
  @media (max-width: 768px) {
84
72
  .session-split {
@@ -173,7 +161,7 @@
173
161
  width: 100%;
174
162
  max-width: 980px;
175
163
  margin: 0 auto;
176
- padding: 12px 12px;
164
+ padding: 8px 16px;
177
165
  }
178
166
 
179
167
  .session-drawer-btn {
@@ -567,10 +555,10 @@
567
555
  display: flex;
568
556
  align-items: center;
569
557
  justify-content: space-between;
570
- gap: 12px;
571
- padding: 12px 12px 6px;
558
+ gap: 8px;
559
+ padding: 6px 16px;
572
560
  border-bottom: 1px solid var(--border);
573
- background: var(--bg-secondary);
561
+ background: var(--bg-primary);
574
562
  }
575
563
 
576
564
  .chat-stream-status {
@@ -581,11 +569,11 @@
581
569
  }
582
570
 
583
571
  .chat-stream-dot {
584
- width: 10px;
585
- height: 10px;
572
+ width: 8px;
573
+ height: 8px;
586
574
  border-radius: 999px;
587
575
  background: var(--text-hint);
588
- box-shadow: 0 0 0 4px rgba(218, 119, 86, 0.1);
576
+ box-shadow: 0 0 0 3px rgba(218, 119, 86, 0.1);
589
577
  }
590
578
 
591
579
  .chat-stream-dot.thinking,
@@ -641,12 +629,16 @@
641
629
  }
642
630
 
643
631
  .chat-stream-meta {
644
- display: flex;
632
+ display: none;
645
633
  flex-direction: column;
646
- gap: 8px;
647
- padding: 6px 12px 10px;
634
+ gap: 6px;
635
+ padding: 4px 16px 6px;
648
636
  border-bottom: 1px solid var(--border);
649
- background: var(--bg-secondary);
637
+ background: var(--bg-primary);
638
+ }
639
+
640
+ .chat-stream-meta.expanded {
641
+ display: flex;
650
642
  }
651
643
 
652
644
  .chat-stream-filters {
@@ -784,11 +776,14 @@
784
776
  scroll-padding-bottom: 80px;
785
777
  scrollbar-width: thin;
786
778
  scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
787
- padding: 20px 16px 48px;
779
+ padding: 24px 24px 48px;
788
780
  display: flex;
789
781
  flex-direction: column;
790
- gap: var(--space-sm);
782
+ gap: 6px;
791
783
  align-items: stretch;
784
+ max-width: 860px;
785
+ margin: 0 auto;
786
+ width: 100%;
792
787
  }
793
788
 
794
789
  .chat-messages::-webkit-scrollbar {
@@ -862,16 +857,16 @@
862
857
  /* ─── Bubbles ─── */
863
858
  .chat-bubble {
864
859
  width: 100%;
865
- max-width: min(760px, 100%);
866
- padding: 10px 14px;
867
- border-radius: var(--radius-md);
868
- font-size: 13px;
869
- line-height: 1.5;
860
+ max-width: 100%;
861
+ padding: 12px 16px;
862
+ border-radius: 18px;
863
+ font-size: 14px;
864
+ line-height: 1.6;
870
865
  word-wrap: break-word;
871
- border: 1px solid transparent;
866
+ border: none;
872
867
  background: var(--bg-card);
873
868
  align-self: flex-start;
874
- box-shadow: var(--shadow-sm);
869
+ box-shadow: none;
875
870
  /* CSS containment — tells the browser each bubble is layout-independent,
876
871
  reducing style recalc and layout thrashing on long message lists */
877
872
  content-visibility: auto;
@@ -882,17 +877,20 @@
882
877
  .chat-bubble.user {
883
878
  align-self: flex-end;
884
879
  width: auto;
885
- max-width: min(560px, 100%);
886
- background: rgba(218, 119, 86, 0.16);
887
- border-color: rgba(218, 119, 86, 0.35);
880
+ max-width: min(75%, 560px);
881
+ background: rgba(218, 119, 86, 0.14);
882
+ border: none;
888
883
  color: var(--text-primary);
884
+ border-radius: 18px 18px 4px 18px;
889
885
  }
890
886
 
891
887
  .chat-bubble.assistant {
892
888
  align-self: flex-start;
893
- background: var(--bg-card);
889
+ background: rgba(255, 255, 255, 0.03);
894
890
  color: var(--text-primary);
895
- border-color: var(--border);
891
+ border: none;
892
+ border-radius: 18px 18px 18px 4px;
893
+ padding: 12px 4px;
896
894
  }
897
895
 
898
896
  .chat-bubble.system {
@@ -901,21 +899,25 @@
901
899
  background: transparent;
902
900
  padding: 6px 12px;
903
901
  box-shadow: none;
902
+ border: none;
904
903
  }
905
904
 
906
905
  .chat-bubble.tool {
907
906
  align-self: center;
908
- background: rgba(218, 119, 86, 0.08);
909
- border: 1px solid rgba(218, 119, 86, 0.28);
907
+ background: rgba(218, 119, 86, 0.06);
908
+ border: 1px solid rgba(218, 119, 86, 0.15);
910
909
  color: var(--text-primary);
911
910
  font-family: var(--font-mono);
911
+ border-radius: 12px;
912
+ font-size: 12px;
912
913
  }
913
914
 
914
915
  .chat-bubble.error {
915
916
  align-self: center;
916
- background: rgba(229, 83, 75, 0.12);
917
- border: 1px solid rgba(229, 83, 75, 0.4);
917
+ background: rgba(229, 83, 75, 0.08);
918
+ border: 1px solid rgba(229, 83, 75, 0.25);
918
919
  color: var(--text-primary);
920
+ border-radius: 12px;
919
921
  }
920
922
 
921
923
  .chat-system-text {
@@ -926,6 +928,7 @@
926
928
  }
927
929
 
928
930
  .chat-bubble-label {
931
+ display: none;
929
932
  font-size: 10px;
930
933
  font-weight: 600;
931
934
  text-transform: uppercase;
@@ -934,14 +937,19 @@
934
937
  margin-bottom: 4px;
935
938
  }
936
939
 
940
+ .chat-bubble.tool .chat-bubble-label,
941
+ .chat-bubble.error .chat-bubble-label {
942
+ display: block;
943
+ }
944
+
937
945
  .chat-bubble-content {
938
946
  white-space: pre-wrap;
939
947
  }
940
948
 
941
949
  .chat-bubble-time {
942
950
  font-size: 10px;
943
- opacity: 0.6;
944
- margin-top: 4px;
951
+ opacity: 0.4;
952
+ margin-top: 6px;
945
953
  }
946
954
 
947
955
  /* Subtle entrance for bubbles — only the last few get the animation
@@ -1908,3 +1916,30 @@ ul.md-list li::before {
1908
1916
  max-width: min(840px, 100%);
1909
1917
  }
1910
1918
  }
1919
+
1920
+ /* ── Focus mode exit FAB ── */
1921
+ .focus-exit-fab {
1922
+ position: fixed;
1923
+ top: 16px;
1924
+ right: 16px;
1925
+ z-index: 10001;
1926
+ width: 40px;
1927
+ height: 40px;
1928
+ border-radius: 50%;
1929
+ border: none;
1930
+ background: #ef4444;
1931
+ color: #fff;
1932
+ font-size: 18px;
1933
+ font-weight: 700;
1934
+ cursor: pointer;
1935
+ display: flex;
1936
+ align-items: center;
1937
+ justify-content: center;
1938
+ box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
1939
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1940
+ -webkit-tap-highlight-color: transparent;
1941
+ }
1942
+ .focus-exit-fab:hover {
1943
+ transform: scale(1.1);
1944
+ box-shadow: 0 6px 20px rgba(239, 68, 68, 0.5);
1945
+ }
package/ui/tabs/agents.js CHANGED
@@ -1280,9 +1280,6 @@ export function AgentsTab() {
1280
1280
  </div>
1281
1281
  `}
1282
1282
 
1283
- <div class="fleet-span">
1284
- <${SessionsPanel} />
1285
- </div>
1286
1283
  </div>
1287
1284
 
1288
1285
  ${selectedAgent && html`
package/ui/tabs/chat.js CHANGED
@@ -691,9 +691,11 @@ export function ChatTab() {
691
691
  html`
692
692
  <div class="chat-shell-header">
693
693
  <div class="chat-shell-inner">
694
- <button class="session-drawer-btn" onClick=${handleBack}>
695
- Sessions
696
- </button>
694
+ ${isMobile && html`
695
+ <button class="session-drawer-btn" onClick=${handleBack}>
696
+ ☰ Sessions
697
+ </button>
698
+ `}
697
699
  <div class="chat-shell-title">
698
700
  <div class="chat-shell-name">${sessionTitle}</div>
699
701
  <div class="chat-shell-meta">${sessionMeta || "Session"}</div>
@@ -784,6 +786,13 @@ export function ChatTab() {
784
786
  </div>
785
787
  </div>
786
788
  </div>
789
+ ${focusMode && html`
790
+ <button
791
+ class="focus-exit-fab"
792
+ onClick=${() => setFocusMode(false)}
793
+ title="Exit focus mode"
794
+ >✕</button>
795
+ `}
787
796
  ${isMobile &&
788
797
  html`
789
798
  <div
@@ -90,26 +90,44 @@ const SETTINGS_STYLES = `
90
90
  /* Floating save bar */
91
91
  .settings-save-bar {
92
92
  position: fixed;
93
- bottom: calc(var(--nav-height) + var(--safe-bottom) + 10px);
94
- left: 0; right: 0;
95
- z-index: 1000;
93
+ bottom: calc(var(--nav-height, 60px) + var(--safe-bottom, 0px) + 12px);
94
+ left: 50%;
95
+ transform: translateX(-50%);
96
+ z-index: 999;
96
97
  display: flex;
97
98
  align-items: center;
98
99
  justify-content: space-between;
99
100
  gap: 12px;
100
101
  flex-wrap: wrap;
101
102
  row-gap: 8px;
102
- padding: 12px 16px;
103
- padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
103
+ padding: 10px 16px;
104
+ min-width: 240px;
105
+ max-width: 480px;
106
+ width: auto;
104
107
  background: var(--glass-bg, rgba(30,30,46,0.95));
105
108
  backdrop-filter: blur(20px);
106
109
  -webkit-backdrop-filter: blur(20px);
107
- border-top: 1px solid var(--border, rgba(255,255,255,0.08));
110
+ border: 1px solid var(--border, rgba(255,255,255,0.08));
111
+ border-radius: 12px;
112
+ box-shadow: 0 4px 20px rgba(0,0,0,0.3);
113
+ transition: all 0.2s ease;
108
114
  animation: slideUp 0.25s ease;
109
115
  }
116
+ .settings-save-bar--dirty {
117
+ border-color: var(--accent, #5b6eae);
118
+ box-shadow: 0 4px 20px rgba(91, 110, 174, 0.25);
119
+ }
120
+ .settings-save-bar--clean .save-bar-info {
121
+ color: var(--text-hint, #666);
122
+ font-size: 12px;
123
+ }
124
+ .setting-modified-dot--clean {
125
+ background: var(--text-hint, #666) !important;
126
+ opacity: 0.4;
127
+ }
110
128
  @keyframes slideUp {
111
- from { transform: translateY(100%); opacity: 0; }
112
- to { transform: translateY(0); opacity: 1; }
129
+ from { transform: translateX(-50%) translateY(20px); opacity: 0; }
130
+ to { transform: translateX(-50%) translateY(0); opacity: 1; }
113
131
  }
114
132
  .settings-save-bar .save-bar-info {
115
133
  display: flex;
@@ -129,7 +147,7 @@ const SETTINGS_STYLES = `
129
147
  }
130
148
  @media (min-width: 1200px) {
131
149
  .settings-save-bar {
132
- bottom: 0;
150
+ bottom: 16px;
133
151
  }
134
152
  }
135
153
  /* Individual setting row */
@@ -359,6 +377,7 @@ const SETTINGS_STYLES = `
359
377
  margin-right: auto;
360
378
  width: 100%;
361
379
  box-sizing: border-box;
380
+ padding-bottom: 80px;
362
381
  }
363
382
 
364
383
  body.settings-save-open .main-content {
@@ -1069,15 +1088,14 @@ function ServerConfigMode() {
1069
1088
  <//>
1070
1089
  `}
1071
1090
 
1072
- <!-- Floating save bar -->
1073
- ${changeCount > 0 &&
1074
- html`
1075
- <div class="settings-save-bar">
1076
- <div class="save-bar-info">
1077
- <span class="setting-modified-dot"></span>
1078
- <span>${changeCount} unsaved change${changeCount !== 1 ? "s" : ""}</span>
1079
- </div>
1080
- <div class="save-bar-actions">
1091
+ <!-- Floating save bar - always visible -->
1092
+ <div class=${`settings-save-bar ${changeCount > 0 ? 'settings-save-bar--dirty' : 'settings-save-bar--clean'}`}>
1093
+ <div class="save-bar-info">
1094
+ <span class=${`setting-modified-dot ${changeCount === 0 ? 'setting-modified-dot--clean' : ''}`}></span>
1095
+ <span>${changeCount > 0 ? `${changeCount} unsaved change${changeCount !== 1 ? "s" : ""}` : "All changes saved"}</span>
1096
+ </div>
1097
+ <div class="save-bar-actions">
1098
+ ${changeCount > 0 && html`
1081
1099
  <button class="btn btn-ghost btn-sm" onClick=${handleDiscard}>
1082
1100
  Discard
1083
1101
  </button>
@@ -1088,9 +1106,9 @@ function ServerConfigMode() {
1088
1106
  >
1089
1107
  ${saving ? html`<${Spinner} size=${14} /> Saving…` : "Save Changes"}
1090
1108
  </button>
1091
- </div>
1109
+ `}
1092
1110
  </div>
1093
- `}
1111
+ </div>
1094
1112
 
1095
1113
  <!-- Confirm dialog with diff -->
1096
1114
  ${confirmOpen &&
@@ -1176,33 +1194,38 @@ function AppPreferencesMode() {
1176
1194
  /* Load prefs from CloudStorage on mount */
1177
1195
  useEffect(() => {
1178
1196
  (async () => {
1179
- const [fs, ct, nu, ne, nc, dm, dmp, ds, dr] = await Promise.all([
1180
- cloudGet("fontSize"),
1181
- cloudGet("colorTheme"),
1182
- cloudGet("notifyUpdates"),
1183
- cloudGet("notifyErrors"),
1184
- cloudGet("notifyComplete"),
1185
- cloudGet("debugMode"),
1186
- cloudGet("defaultMaxParallel"),
1187
- cloudGet("defaultSdk"),
1188
- cloudGet("defaultRegion"),
1189
- ]);
1190
- if (fs) {
1191
- setFontSize(fs);
1192
- applyFontSize(fs);
1193
- }
1194
- if (ct) {
1195
- setColorTheme(ct);
1196
- applyColorTheme(ct);
1197
+ try {
1198
+ const [fs, ct, nu, ne, nc, dm, dmp, ds, dr] = await Promise.all([
1199
+ cloudGet("fontSize"),
1200
+ cloudGet("colorTheme"),
1201
+ cloudGet("notifyUpdates"),
1202
+ cloudGet("notifyErrors"),
1203
+ cloudGet("notifyComplete"),
1204
+ cloudGet("debugMode"),
1205
+ cloudGet("defaultMaxParallel"),
1206
+ cloudGet("defaultSdk"),
1207
+ cloudGet("defaultRegion"),
1208
+ ]);
1209
+ if (fs) {
1210
+ setFontSize(fs);
1211
+ applyFontSize(fs);
1212
+ }
1213
+ if (ct) {
1214
+ setColorTheme(ct);
1215
+ applyColorTheme(ct);
1216
+ }
1217
+ if (nu != null) setNotifyUpdates(nu);
1218
+ if (ne != null) setNotifyErrors(ne);
1219
+ if (nc != null) setNotifyComplete(nc);
1220
+ if (dm != null) setDebugMode(dm);
1221
+ if (dmp != null) setDefaultMaxParallel(dmp);
1222
+ if (ds) setDefaultSdk(ds);
1223
+ if (dr) setDefaultRegion(dr);
1224
+ } catch (err) {
1225
+ console.warn('[AppPrefs] Failed to load preferences:', err);
1226
+ } finally {
1227
+ setLoaded(true);
1197
1228
  }
1198
- if (nu != null) setNotifyUpdates(nu);
1199
- if (ne != null) setNotifyErrors(ne);
1200
- if (nc != null) setNotifyComplete(nc);
1201
- if (dm != null) setDebugMode(dm);
1202
- if (dmp != null) setDefaultMaxParallel(dmp);
1203
- if (ds) setDefaultSdk(ds);
1204
- if (dr) setDefaultRegion(dr);
1205
- setLoaded(true);
1206
1229
  })();
1207
1230
  }, []);
1208
1231
 
@@ -1211,6 +1234,7 @@ function AppPreferencesMode() {
1211
1234
  const next = !getter;
1212
1235
  setter(next);
1213
1236
  cloudSet(key, next);
1237
+ console.log('[AppPrefs] Saved:', key, next);
1214
1238
  haptic();
1215
1239
  showToast("Preference saved", "success");
1216
1240
  }, []);
@@ -1218,6 +1242,7 @@ function AppPreferencesMode() {
1218
1242
  const handleFontSize = (v) => {
1219
1243
  setFontSize(v);
1220
1244
  cloudSet("fontSize", v);
1245
+ console.log('[AppPrefs] Saved: fontSize', v);
1221
1246
  haptic();
1222
1247
  applyFontSize(v);
1223
1248
  showToast("Font size saved", "success");
@@ -1226,6 +1251,7 @@ function AppPreferencesMode() {
1226
1251
  const handleColorTheme = (v) => {
1227
1252
  setColorTheme(v);
1228
1253
  cloudSet("colorTheme", v);
1254
+ console.log('[AppPrefs] Saved: colorTheme', v);
1229
1255
  haptic();
1230
1256
  applyColorTheme(v);
1231
1257
  showToast("Theme saved", "success");
@@ -1235,6 +1261,7 @@ function AppPreferencesMode() {
1235
1261
  const val = Math.max(1, Math.min(20, Number(v)));
1236
1262
  setDefaultMaxParallel(val);
1237
1263
  cloudSet("defaultMaxParallel", val);
1264
+ console.log('[AppPrefs] Saved: defaultMaxParallel', val);
1238
1265
  haptic();
1239
1266
  showToast("Preference saved", "success");
1240
1267
  };
@@ -1242,6 +1269,7 @@ function AppPreferencesMode() {
1242
1269
  const handleDefaultSdk = (v) => {
1243
1270
  setDefaultSdk(v);
1244
1271
  cloudSet("defaultSdk", v);
1272
+ console.log('[AppPrefs] Saved: defaultSdk', v);
1245
1273
  haptic();
1246
1274
  showToast("Preference saved", "success");
1247
1275
  };
@@ -1249,6 +1277,7 @@ function AppPreferencesMode() {
1249
1277
  const handleDefaultRegion = (v) => {
1250
1278
  setDefaultRegion(v);
1251
1279
  cloudSet("defaultRegion", v);
1280
+ console.log('[AppPrefs] Saved: defaultRegion', v);
1252
1281
  haptic();
1253
1282
  showToast("Preference saved", "success");
1254
1283
  };
@@ -1312,7 +1341,7 @@ function AppPreferencesMode() {
1312
1341
  : "";
1313
1342
 
1314
1343
  return html`
1315
- ${!loaded && html`<${Card} title="Loading Settings…"><${SkeletonCard} /><//>`}
1344
+
1316
1345
 
1317
1346
  <!-- ─── Account ─── -->
1318
1347
  <${Collapsible} title="👤 Account" defaultOpen=${true}>
package/ui/tabs/tasks.js CHANGED
@@ -214,6 +214,27 @@ export function StartTaskModal({
214
214
  }, [task?.id, task?.meta?.codex?.isIgnored, task?.meta?.labels]);
215
215
 
216
216
  const canModel = sdk && sdk !== "auto";
217
+
218
+ const EXECUTOR_MODELS = {
219
+ codex: [
220
+ "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex",
221
+ "gpt-5.1-codex-mini", "gpt-5.1-codex-max",
222
+ "gpt-5.2", "gpt-5.1", "gpt-5-mini",
223
+ ],
224
+ copilot: [
225
+ "claude-opus-4.6", "claude-sonnet-4.6", "claude-opus-4.5", "claude-sonnet-4.5",
226
+ "claude-sonnet-4", "claude-haiku-4.5",
227
+ "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.1-codex", "gpt-5.1-codex-mini",
228
+ "gpt-5.2", "gpt-5.1", "gpt-5-mini",
229
+ "gemini-3.1-pro", "gemini-3-pro", "gemini-3-flash", "gemini-2.5-pro",
230
+ "grok-code-fast-1",
231
+ ],
232
+ claude: [
233
+ "claude-opus-4.6", "claude-sonnet-4.6", "claude-opus-4.5",
234
+ "claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5",
235
+ ],
236
+ };
237
+
217
238
  const resolvedTaskId = (task?.id || taskIdInput || "").trim();
218
239
 
219
240
  const handleStart = async () => {
@@ -272,13 +293,10 @@ export function StartTaskModal({
272
293
  </div>
273
294
  <div class="modal-form-field">
274
295
  <div class="card-subtitle">Model Override (optional)</div>
275
- <input
276
- class="input"
277
- placeholder=${canModel ? "e.g. gpt-5.3-codex" : "Select SDK to enable"}
278
- value=${model}
279
- disabled=${!canModel}
280
- onInput=${(e) => setModel(e.target.value)}
281
- />
296
+ <select class="input" value=${model} disabled=${!canModel} onChange=${(e) => setModel(e.target.value)}>
297
+ <option value="">Auto (default)</option>
298
+ ${canModel && (EXECUTOR_MODELS[sdk] || []).map(m => html`<option value=${m}>${m}</option>`)}
299
+ </select>
282
300
  </div>
283
301
  <div class="modal-form-field modal-form-span">
284
302
  <button
package/ui-server.mjs CHANGED
@@ -110,7 +110,7 @@ let _configValidator = null;
110
110
  function resolveConfigPath() {
111
111
  return process.env.BOSUN_CONFIG_PATH
112
112
  ? resolve(process.env.BOSUN_CONFIG_PATH)
113
- : resolve(__dirname, "bosun.config.json");
113
+ : resolve(resolveUiConfigDir(), "bosun.config.json");
114
114
  }
115
115
 
116
116
  function getConfigSchema() {
@@ -546,6 +546,8 @@ let uiServer = null;
546
546
  let uiServerUrl = null;
547
547
  let uiServerTls = false;
548
548
  let wsServer = null;
549
+ /** Auto-open browser: only once per process, never during tests */
550
+ let _browserOpened = false;
549
551
  const wsClients = new Set();
550
552
  let sessionListenerAttached = false;
551
553
  /** @type {ReturnType<typeof setInterval>|null} */
@@ -660,7 +662,7 @@ const SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
660
662
  let _settingsLastUpdateTime = 0;
661
663
 
662
664
  function updateEnvFile(changes) {
663
- const envPath = resolve(__dirname, '.env');
665
+ const envPath = resolve(resolveUiConfigDir(), '.env');
664
666
  let content = '';
665
667
  try { content = readFileSync(envPath, 'utf8'); } catch { content = ''; }
666
668
 
@@ -4339,7 +4341,7 @@ async function handleApi(req, res, url) {
4339
4341
  data[key] = val || "";
4340
4342
  }
4341
4343
  }
4342
- const envPath = resolve(__dirname, ".env");
4344
+ const envPath = resolve(resolveUiConfigDir(), ".env");
4343
4345
  const configPath = resolveConfigPath();
4344
4346
  const configExists = existsSync(configPath);
4345
4347
  const configSchema = getConfigSchema();
@@ -5468,6 +5470,32 @@ export async function startTelegramUiServer(options = {}) {
5468
5470
  console.log(`[telegram-ui] LAN access: ${protocol}://${lanIp}:${actualPort}`);
5469
5471
  console.log(`[telegram-ui] Browser access: ${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`);
5470
5472
 
5473
+ // Auto-open browser:
5474
+ // - skip in desktop/Electron mode (BOSUN_DESKTOP=1)
5475
+ // - skip when caller passes skipAutoOpen
5476
+ // - skip during Vitest / Jest test runs (avoids opening 20+ tabs during `npm test`)
5477
+ // - only open ONCE per process (singleton guard — prevents loops on server restart)
5478
+ const isTestRun = process.env.VITEST || process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID;
5479
+ if (
5480
+ process.env.BOSUN_DESKTOP !== "1" &&
5481
+ !options.skipAutoOpen &&
5482
+ !_browserOpened &&
5483
+ !isTestRun
5484
+ ) {
5485
+ _browserOpened = true;
5486
+ const openUrl = `${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`;
5487
+ try {
5488
+ const { exec } = await import("node:child_process");
5489
+ if (process.platform === "win32") {
5490
+ exec(`start "" "${openUrl}"`);
5491
+ } else if (process.platform === "darwin") {
5492
+ exec(`open "${openUrl}"`);
5493
+ } else {
5494
+ exec(`xdg-open "${openUrl}"`);
5495
+ }
5496
+ } catch { /* ignore auto-open failure */ }
5497
+ }
5498
+
5471
5499
  // Check firewall rules for the UI port
5472
5500
  firewallState = await checkFirewall(actualPort);
5473
5501
  if (firewallState) {