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
@@ -91,36 +91,153 @@ function normalizePreview(content) {
91
91
  return text.slice(0, 100);
92
92
  }
93
93
 
94
+ function canonicalMessageKind(msg) {
95
+ const role = String(msg?.role || "").trim().toLowerCase();
96
+ const type = String(msg?.type || "").trim().toLowerCase();
97
+ if (
98
+ role === "assistant" ||
99
+ type === "agent_message" ||
100
+ type === "assistant" ||
101
+ type === "assistant_message"
102
+ ) {
103
+ return "assistant";
104
+ }
105
+ if (role === "user" || type === "user") return "user";
106
+ if (type === "tool_call") return "tool_call";
107
+ if (type === "tool_result" || type === "tool_output") return "tool_result";
108
+ if (type === "error" || type === "stream_error") return "error";
109
+ if (role === "system" || type === "system") return "system";
110
+ return `${role || "unknown"}:${type || "message"}`;
111
+ }
112
+
113
+ function messageBody(msg) {
114
+ const value = msg?.content ?? msg?.text ?? "";
115
+ return String(value || "").trim();
116
+ }
117
+
118
+ function isLifecycleSystemMessage(msg) {
119
+ if (canonicalMessageKind(msg) !== "system") return false;
120
+ const lifecycle = String(msg?.meta?.lifecycle || "").trim().toLowerCase();
121
+ if (lifecycle) return true;
122
+ const content = messageBody(msg).toLowerCase();
123
+ if (!content) return true;
124
+ return (
125
+ content === "turn completed" ||
126
+ content === "session completed" ||
127
+ content === "agent is composing a response..." ||
128
+ content === "agent is composing a response…" ||
129
+ content.startsWith("message_stop") ||
130
+ content.startsWith("message_delta")
131
+ );
132
+ }
133
+
134
+ function reconnectFingerprint(content) {
135
+ const text = String(content || "").trim();
136
+ if (!text) return "";
137
+ const lower = text.toLowerCase();
138
+ if (!lower.includes("stream disconnected")) return "";
139
+ return lower
140
+ .replace(/reconnecting\.\.\.\s*\d+\s*\/\s*\d+/g, "reconnecting... n/n")
141
+ .replace(/\s+/g, " ")
142
+ .trim();
143
+ }
144
+
145
+ function isDecorativeLine(text) {
146
+ const compact = String(text || "").replace(/\s+/g, "");
147
+ if (!compact) return true;
148
+ if (/^[\-=_*`~.·•]+$/.test(compact)) return true;
149
+ if (/^[\u2500-\u257f]+$/u.test(compact)) return true;
150
+ return false;
151
+ }
152
+
94
153
  function dedupeMessages(messages) {
95
154
  const list = Array.isArray(messages) ? messages : [];
96
155
  const out = [];
97
156
  const seenExact = new Set();
157
+ const recentAssistantContentTs = new Map();
158
+ const reconnectIndexByFingerprint = new Map();
98
159
  for (const msg of list) {
99
160
  if (!msg) continue;
100
- const role = String(msg.role || "");
101
- const type = String(msg.type || "");
102
- const content = String(msg.content || msg.text || "").trim();
161
+ const kind = canonicalMessageKind(msg);
162
+ const content = messageBody(msg);
163
+ if (!content && kind !== "user") continue;
164
+ if (isLifecycleSystemMessage(msg)) continue;
165
+ if (kind === "system" && isDecorativeLine(content)) continue;
166
+ if (
167
+ kind === "assistant" &&
168
+ content.length <= 2 &&
169
+ !/[a-z0-9]/i.test(content)
170
+ ) {
171
+ continue;
172
+ }
103
173
  const ts = Date.parse(msg.timestamp || 0) || 0;
104
- const exactKey = `${role}|${type}|${content}|${ts}`;
174
+ const exactKey = `${kind}|${content}|${ts}`;
105
175
  if (seenExact.has(exactKey)) continue;
176
+ const reconnectKey = kind === "error" ? reconnectFingerprint(content) : "";
177
+ const normalizedMsg =
178
+ reconnectKey && !msg.id ? { ...msg, id: `reconnect:${reconnectKey}` } : msg;
179
+ if (reconnectKey) {
180
+ const existingIndex = reconnectIndexByFingerprint.get(reconnectKey);
181
+ if (Number.isInteger(existingIndex) && existingIndex >= 0 && existingIndex < out.length) {
182
+ const existing = out[existingIndex];
183
+ out[existingIndex] = existing?.id
184
+ ? { ...normalizedMsg, id: existing.id }
185
+ : normalizedMsg;
186
+ seenExact.add(exactKey);
187
+ continue;
188
+ }
189
+ }
190
+ if (kind === "assistant" && content) {
191
+ while (out.length > 0 && isLifecycleSystemMessage(out[out.length - 1])) {
192
+ out.pop();
193
+ }
194
+ const lastAssistant = out[out.length - 1];
195
+ if (lastAssistant && canonicalMessageKind(lastAssistant) === "assistant") {
196
+ const lastTs = Date.parse(lastAssistant.timestamp || 0) || 0;
197
+ const withinStreamingWindow =
198
+ ts > 0 && lastTs > 0 ? Math.abs(ts - lastTs) <= 120000 : true;
199
+ if (withinStreamingWindow) {
200
+ out[out.length - 1] = lastAssistant?.id
201
+ ? { ...normalizedMsg, id: lastAssistant.id }
202
+ : normalizedMsg;
203
+ recentAssistantContentTs.set(content, ts);
204
+ seenExact.add(exactKey);
205
+ continue;
206
+ }
207
+ }
208
+ const prevAssistantTs = recentAssistantContentTs.get(content);
209
+ if (prevAssistantTs !== undefined) {
210
+ const withinAssistantWindow =
211
+ ts > 0 && prevAssistantTs > 0
212
+ ? Math.abs(ts - prevAssistantTs) <= 5000
213
+ : true;
214
+ if (withinAssistantWindow) continue;
215
+ }
216
+ }
106
217
  const last = out[out.length - 1];
107
218
  if (last) {
108
- const lastRole = String(last.role || "");
109
- const lastType = String(last.type || "");
219
+ const lastKind = canonicalMessageKind(last);
110
220
  const lastContent = String(last.content || last.text || "").trim();
111
221
  const lastTs = Date.parse(last.timestamp || 0) || 0;
222
+ const withinDuplicateWindow =
223
+ ts > 0 && lastTs > 0 ? Math.abs(ts - lastTs) <= 5000 : true;
112
224
  if (
113
225
  content &&
114
- lastRole === role &&
115
- lastType === type &&
226
+ lastKind === kind &&
116
227
  lastContent === content &&
117
- Math.abs(ts - lastTs) <= 5000
228
+ withinDuplicateWindow
118
229
  ) {
119
230
  continue;
120
231
  }
121
232
  }
122
233
  seenExact.add(exactKey);
123
- out.push(msg);
234
+ out.push(normalizedMsg);
235
+ if (reconnectKey) {
236
+ reconnectIndexByFingerprint.set(reconnectKey, out.length - 1);
237
+ }
238
+ if (kind === "assistant" && content) {
239
+ recentAssistantContentTs.set(content, ts);
240
+ }
124
241
  }
125
242
  return out;
126
243
  }
@@ -296,6 +413,32 @@ const STATUS_COLOR_MAP = {
296
413
  archived: "var(--text-hint)",
297
414
  };
298
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
+
299
442
  /* ─── Swipeable Session Item ─── */
300
443
  function SwipeableSessionItem({
301
444
  session: s,
@@ -406,7 +549,7 @@ function SwipeableSessionItem({
406
549
  onClick=${handleResume}
407
550
  title="Unarchive"
408
551
  >
409
- <span class="session-action-icon">↩</span>
552
+ <span class="session-action-icon">:workflow:</span>
410
553
  <span class="session-action-label">Restore</span>
411
554
  </button>
412
555
  `
@@ -416,7 +559,7 @@ function SwipeableSessionItem({
416
559
  onClick=${handleArchive}
417
560
  title="Archive session"
418
561
  >
419
- <span class="session-action-icon">${resolveIcon("📦")}</span>
562
+ <span class="session-action-icon">${resolveIcon(":box:")}</span>
420
563
  <span class="session-action-label">Archive</span>
421
564
  </button>
422
565
  `}
@@ -425,7 +568,7 @@ function SwipeableSessionItem({
425
568
  onClick=${handleDelete}
426
569
  title=${confirmDelete ? "Confirm delete" : "Delete session"}
427
570
  >
428
- <span class="session-action-icon">${resolveIcon(confirmDelete ? "⚠️" : "🗑")}</span>
571
+ <span class="session-action-icon">${resolveIcon(confirmDelete ? ":alert:" : ":trash:")}</span>
429
572
  <span class="session-action-label">${confirmDelete ? "Sure?" : "Delete"}</span>
430
573
  </button>
431
574
  </div>
@@ -522,6 +665,8 @@ export function SessionList({
522
665
  onSelect,
523
666
  showArchived = true,
524
667
  onToggleArchived,
668
+ sessionView = SESSION_VIEW_FILTER.all,
669
+ onSessionViewChange,
525
670
  defaultType = null,
526
671
  renamingSessionId = null,
527
672
  onStartRename,
@@ -530,9 +675,36 @@ export function SessionList({
530
675
  }) {
531
676
  const [search, setSearch] = useState("");
532
677
  const [revealedActions, setRevealedActions] = useState(null);
678
+ const [uncontrolledSessionView, setUncontrolledSessionView] = useState(
679
+ normalizeSessionViewFilter(sessionView),
680
+ );
533
681
  const allSessions = sessionsData.value || [];
534
682
  const error = sessionsError.value;
535
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
+ );
536
708
 
537
709
  // Filter by defaultType to exclude ghost sessions (e.g. task sessions in Chat tab)
538
710
  const typeFiltered = defaultType
@@ -549,30 +721,39 @@ export function SessionList({
549
721
  })
550
722
  : allSessions;
551
723
 
552
- const base = showArchived
724
+ const archivedFiltered = showArchived
553
725
  ? typeFiltered
554
- : 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
+ });
555
737
 
556
738
  const filtered = search
557
- ? base.filter(
739
+ ? viewFiltered.filter(
558
740
  (s) =>
559
741
  (s.title || "").toLowerCase().includes(search.toLowerCase()) ||
560
742
  (s.taskId || "").toLowerCase().includes(search.toLowerCase()),
561
743
  )
562
- : base;
744
+ : viewFiltered;
563
745
 
564
- const active = filtered.filter(
565
- (s) => s.status === "active" || s.status === "running",
566
- );
567
- 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");
568
748
  const recent = filtered.filter(
569
749
  (s) =>
570
- s.status !== "active" &&
571
- s.status !== "running" &&
572
- s.status !== "archived",
750
+ !isActiveSession(s) && getSessionStatusKey(s) !== "archived",
573
751
  );
574
752
 
575
- 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;
576
757
 
577
758
  const handleSelect = useCallback(
578
759
  (id) => {
@@ -588,6 +769,13 @@ export function SessionList({
588
769
  loadSessions(_lastLoadFilter);
589
770
  }, []);
590
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
+
591
779
  const handleArchive = useCallback(async (id) => {
592
780
  setRevealedActions(null);
593
781
  await archiveSession(id);
@@ -609,6 +797,21 @@ export function SessionList({
609
797
  setRevealedActions(null);
610
798
  }, []);
611
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
+
612
815
  /* ── Render session items ── */
613
816
  function renderSessionItem(s) {
614
817
  return html`
@@ -637,7 +840,7 @@ export function SessionList({
637
840
  <span class="session-list-title">Sessions</span>
638
841
  </div>
639
842
  <div class="session-empty">
640
- <div class="session-empty-icon">${resolveIcon("📡")}</div>
843
+ <div class="session-empty-icon">${resolveIcon(":server:")}</div>
641
844
  <div class="session-empty-text">Sessions not available</div>
642
845
  <button class="btn btn-primary btn-sm" onClick=${handleRetry}>
643
846
  Retry
@@ -666,8 +869,7 @@ export function SessionList({
666
869
  `}
667
870
  <button
668
871
  class="btn btn-primary btn-sm"
669
- onClick=${() =>
670
- createSession(defaultType ? { type: defaultType } : {})}
872
+ onClick=${handleCreateSession}
671
873
  >
672
874
  + New
673
875
  </button>
@@ -683,6 +885,27 @@ export function SessionList({
683
885
  />
684
886
  </div>
685
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
+
686
909
  <div class="session-list-scroll">
687
910
  ${active.length > 0 &&
688
911
  html`
@@ -702,20 +925,17 @@ export function SessionList({
702
925
  ${filtered.length === 0 &&
703
926
  html`
704
927
  <div class="session-empty">
705
- <div class="session-empty-icon">${resolveIcon("💬")}</div>
928
+ <div class="session-empty-icon">${resolveIcon(":chat:")}</div>
706
929
  <div class="session-empty-text">
707
- ${hasSearch ? "No matching sessions" : "No sessions yet"}
930
+ ${emptyTitle}
708
931
  <div class="session-empty-subtext">
709
- ${hasSearch
710
- ? "Try a different keyword or clear the search."
711
- : "Create a session to get started."}
932
+ ${emptyHint}
712
933
  </div>
713
934
  </div>
714
935
  <div class="session-empty-actions">
715
936
  <button
716
937
  class="btn btn-primary btn-sm"
717
- onClick=${() =>
718
- createSession(defaultType ? { type: defaultType } : {})}
938
+ onClick=${handleCreateSession}
719
939
  >
720
940
  + New Session
721
941
  </button>
@@ -312,12 +312,12 @@ function WorkspaceCard({ ws }) {
312
312
  onClick=${handlePull}
313
313
  disabled=${pulling}
314
314
  title="Pull all repos"
315
- >${pulling ? html`<${Spinner} /> Pulling` : iconText("🔄 Pull")}</button>
315
+ >${pulling ? html`<${Spinner} /> Pulling` : iconText(":refresh: Pull")}</button>
316
316
  <button
317
317
  class="ws-manager-btn ghost sm danger-text"
318
318
  onClick=${() => { haptic("light"); setDelConfirm(true); }}
319
319
  title="Delete workspace"
320
- >${resolveIcon("🗑")}</button>
320
+ >${resolveIcon(":trash:")}</button>
321
321
  </div>
322
322
  </div>
323
323
 
@@ -425,7 +425,7 @@ export function WorkspaceManager({ open, onClose }) {
425
425
  onClick=${handleScan}
426
426
  disabled=${scanning}
427
427
  title="Scan disk for workspaces"
428
- >${scanning ? "Scanning…" : iconText("🔍 Scan Disk")}</button>
428
+ >${scanning ? "Scanning…" : iconText(":search: Scan Disk")}</button>
429
429
  </div>
430
430
 
431
431
  ${loading && !wsList.length