bosun 0.36.2 → 0.36.4
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/agent-prompts.mjs +95 -0
- package/analyze-agent-work-helpers.mjs +308 -0
- package/analyze-agent-work.mjs +926 -0
- package/autofix.mjs +2 -0
- package/bosun.schema.json +101 -3
- package/codex-shell.mjs +85 -10
- package/desktop/main.mjs +871 -48
- package/desktop/preload.mjs +54 -1
- package/desktop-shortcut.mjs +90 -11
- package/git-editor-fix.mjs +273 -0
- package/mcp-registry.mjs +579 -0
- package/meeting-workflow-service.mjs +631 -0
- package/monitor.mjs +18 -103
- package/package.json +21 -2
- package/primary-agent.mjs +32 -12
- package/session-tracker.mjs +68 -0
- package/setup-web-server.mjs +20 -10
- package/setup.mjs +376 -83
- package/startup-service.mjs +51 -6
- package/stream-resilience.mjs +17 -7
- package/ui/app.js +164 -4
- package/ui/components/agent-selector.js +145 -1
- package/ui/components/chat-view.js +161 -15
- package/ui/components/session-list.js +2 -2
- package/ui/components/shared.js +188 -15
- package/ui/modules/icons.js +13 -0
- package/ui/modules/utils.js +44 -0
- package/ui/modules/voice-client-sdk.js +733 -0
- package/ui/modules/voice-overlay.js +128 -15
- package/ui/modules/voice.js +15 -6
- package/ui/setup.html +281 -81
- package/ui/styles/components.css +99 -3
- package/ui/styles/sessions.css +122 -14
- package/ui/styles.css +14 -0
- package/ui/tabs/agents.js +1 -1
- package/ui/tabs/chat.js +123 -14
- package/ui/tabs/control.js +16 -22
- package/ui/tabs/dashboard.js +85 -8
- package/ui/tabs/library.js +113 -17
- package/ui/tabs/settings.js +116 -2
- package/ui/tabs/tasks.js +388 -39
- package/ui/tabs/telemetry.js +0 -1
- package/ui/tabs/workflows.js +4 -0
- package/ui-server.mjs +400 -22
- package/update-check.mjs +41 -13
- package/voice-action-dispatcher.mjs +844 -0
- package/voice-agents-sdk.mjs +664 -0
- package/voice-auth-manager.mjs +164 -0
- package/voice-relay.mjs +1194 -0
- package/voice-tools.mjs +914 -0
- package/workflow-templates/agents.mjs +6 -2
- package/workflow-templates/github.mjs +154 -12
- package/workflow-templates.mjs +3 -0
- package/github-reconciler.mjs +0 -506
- package/merge-strategy.mjs +0 -1210
- package/pr-cleanup-daemon.mjs +0 -992
- package/workspace-reaper.mjs +0 -405
package/ui/tabs/library.js
CHANGED
|
@@ -11,11 +11,25 @@ const html = htm.bind(h);
|
|
|
11
11
|
|
|
12
12
|
import { haptic } from "../modules/telegram.js";
|
|
13
13
|
import { apiFetch } from "../modules/api.js";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
showToast,
|
|
16
|
+
refreshTab,
|
|
17
|
+
setPendingChange,
|
|
18
|
+
clearPendingChange,
|
|
19
|
+
} from "../modules/state.js";
|
|
15
20
|
import { ICONS } from "../modules/icons.js";
|
|
16
21
|
import { iconText, resolveIcon } from "../modules/icon-utils.js";
|
|
17
|
-
import { formatRelative } from "../modules/utils.js";
|
|
18
|
-
import {
|
|
22
|
+
import { formatRelative, countChangedFields } from "../modules/utils.js";
|
|
23
|
+
import {
|
|
24
|
+
Card,
|
|
25
|
+
Badge,
|
|
26
|
+
EmptyState,
|
|
27
|
+
Modal,
|
|
28
|
+
ConfirmDialog,
|
|
29
|
+
Spinner,
|
|
30
|
+
ListItem,
|
|
31
|
+
SaveDiscardBar,
|
|
32
|
+
} from "../components/shared.js";
|
|
19
33
|
import { SearchInput, SegmentedControl, Toggle } from "../components/forms.js";
|
|
20
34
|
|
|
21
35
|
/* ═══════════════════════════════════════════════════════════════
|
|
@@ -41,14 +55,17 @@ const LIBRARY_STYLES = `
|
|
|
41
55
|
|
|
42
56
|
.library-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 12px; }
|
|
43
57
|
.library-card { background: var(--bg-card, #1a1a2e); border: 1px solid var(--border, #333);
|
|
44
|
-
border-radius: 12px; padding: 16px; cursor: pointer; transition: all 0.15s; position: relative; }
|
|
58
|
+
border-radius: 12px; padding: 16px; padding-right: 96px; cursor: pointer; transition: all 0.15s; position: relative; }
|
|
45
59
|
.library-card:hover { border-color: var(--accent, #58a6ff); transform: translateY(-1px);
|
|
46
60
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3); }
|
|
47
61
|
|
|
48
62
|
.library-card-header { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; }
|
|
49
63
|
.library-card-icon { font-size: 1.4em; flex-shrink: 0; width: 32px; text-align: center; }
|
|
50
64
|
.library-card-icon svg { width: 20px; height: 20px; vertical-align: middle; }
|
|
51
|
-
.library-card-
|
|
65
|
+
.library-card-header > div { min-width: 0; flex: 1; }
|
|
66
|
+
.library-card-title { font-weight: 600; font-size: 0.95em; color: var(--text-primary, #eee);
|
|
67
|
+
overflow-wrap: anywhere; word-break: break-word;
|
|
68
|
+
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
52
69
|
.library-card-desc { font-size: 0.82em; color: var(--text-secondary, #aaa); margin-bottom: 8px;
|
|
53
70
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
|
54
71
|
.library-card-meta { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
@@ -123,7 +140,7 @@ const LIBRARY_STYLES = `
|
|
|
123
140
|
.library-toolbar { gap: 6px; }
|
|
124
141
|
.library-toolbar .search-wrap { flex: 1 1 100%; min-width: 0; }
|
|
125
142
|
.library-grid { grid-template-columns: 1fr; }
|
|
126
|
-
.library-card { padding: 12px; }
|
|
143
|
+
.library-card { padding: 12px; padding-right: 84px; }
|
|
127
144
|
.library-card-scope { margin-left: 0; }
|
|
128
145
|
.library-actions { flex-wrap: wrap; justify-content: stretch; }
|
|
129
146
|
.library-actions button { flex: 1 1 140px; padding: 10px 12px; }
|
|
@@ -307,7 +324,7 @@ function LibraryCard({ entry, onSelect }) {
|
|
|
307
324
|
|
|
308
325
|
function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
309
326
|
const isNew = !entry?.id;
|
|
310
|
-
const
|
|
327
|
+
const initialFormSnapshot = {
|
|
311
328
|
id: entry?.id || "",
|
|
312
329
|
type: entry?.type || "prompt",
|
|
313
330
|
name: entry?.name || "",
|
|
@@ -315,10 +332,31 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
|
315
332
|
tags: (entry?.tags || []).join(", "),
|
|
316
333
|
scope: entry?.scope || "global",
|
|
317
334
|
content: "",
|
|
318
|
-
}
|
|
335
|
+
};
|
|
336
|
+
const [form, setForm] = useState(initialFormSnapshot);
|
|
337
|
+
const [baseline, setBaseline] = useState(initialFormSnapshot);
|
|
319
338
|
const [loading, setLoading] = useState(false);
|
|
320
339
|
const [loadingContent, setLoadingContent] = useState(!isNew && !!entry?.id);
|
|
321
340
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
341
|
+
const pendingKey = useMemo(
|
|
342
|
+
() => `modal:library-entry:${entry?.id || "new"}`,
|
|
343
|
+
[entry?.id],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
const next = {
|
|
348
|
+
id: entry?.id || "",
|
|
349
|
+
type: entry?.type || "prompt",
|
|
350
|
+
name: entry?.name || "",
|
|
351
|
+
description: entry?.description || "",
|
|
352
|
+
tags: (entry?.tags || []).join(", "),
|
|
353
|
+
scope: entry?.scope || "global",
|
|
354
|
+
content: "",
|
|
355
|
+
};
|
|
356
|
+
setForm(next);
|
|
357
|
+
setBaseline(next);
|
|
358
|
+
setLoadingContent(!isNew && !!entry?.id);
|
|
359
|
+
}, [entry?.id]);
|
|
322
360
|
|
|
323
361
|
// Load content for existing entries
|
|
324
362
|
useEffect(() => {
|
|
@@ -330,7 +368,11 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
|
330
368
|
if (cancelled) return;
|
|
331
369
|
let contentStr = detail?.content ?? "";
|
|
332
370
|
if (typeof contentStr === "object") contentStr = JSON.stringify(contentStr, null, 2);
|
|
333
|
-
setForm((f) =>
|
|
371
|
+
setForm((f) => {
|
|
372
|
+
const next = { ...f, content: contentStr };
|
|
373
|
+
setBaseline(next);
|
|
374
|
+
return next;
|
|
375
|
+
});
|
|
334
376
|
} catch { /* ignore */ }
|
|
335
377
|
setLoadingContent(false);
|
|
336
378
|
})();
|
|
@@ -338,9 +380,27 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
|
338
380
|
}, [entry?.id]);
|
|
339
381
|
|
|
340
382
|
const updateField = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
|
|
383
|
+
const changeCount = useMemo(
|
|
384
|
+
() => countChangedFields(baseline, form),
|
|
385
|
+
[baseline, form],
|
|
386
|
+
);
|
|
387
|
+
const hasUnsaved = changeCount > 0;
|
|
341
388
|
|
|
342
|
-
|
|
343
|
-
|
|
389
|
+
useEffect(() => {
|
|
390
|
+
setPendingChange(pendingKey, hasUnsaved);
|
|
391
|
+
return () => clearPendingChange(pendingKey);
|
|
392
|
+
}, [hasUnsaved, pendingKey]);
|
|
393
|
+
|
|
394
|
+
const resetToBaseline = useCallback(() => {
|
|
395
|
+
setForm(baseline);
|
|
396
|
+
showToast("Changes discarded", "info");
|
|
397
|
+
}, [baseline]);
|
|
398
|
+
|
|
399
|
+
const handleSave = useCallback(async ({ closeAfterSave = true } = {}) => {
|
|
400
|
+
if (!form.name.trim()) {
|
|
401
|
+
showToast("Name is required", "error");
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
344
404
|
setLoading(true);
|
|
345
405
|
try {
|
|
346
406
|
const tags = form.tags.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
@@ -359,14 +419,23 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
|
359
419
|
});
|
|
360
420
|
if (res?.ok) {
|
|
361
421
|
showToast(`${TYPE_LABELS[form.type] || "Entry"} saved`, "success");
|
|
362
|
-
|
|
422
|
+
const nextBaseline = { ...form };
|
|
423
|
+
setBaseline(nextBaseline);
|
|
424
|
+
if (closeAfterSave) {
|
|
425
|
+
onSaved?.();
|
|
426
|
+
return { closed: true };
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
363
429
|
} else {
|
|
364
430
|
showToast(res?.error || "Save failed", "error");
|
|
431
|
+
return false;
|
|
365
432
|
}
|
|
366
433
|
} catch (err) {
|
|
367
434
|
showToast(err.message, "error");
|
|
435
|
+
return false;
|
|
436
|
+
} finally {
|
|
437
|
+
setLoading(false);
|
|
368
438
|
}
|
|
369
|
-
setLoading(false);
|
|
370
439
|
}, [form, onSaved]);
|
|
371
440
|
|
|
372
441
|
const handleDelete = useCallback(async () => {
|
|
@@ -402,7 +471,17 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
|
402
471
|
: "# Skill Title\n\n## Purpose\nDescribe what this skill teaches agents.\n\n## Instructions\n...";
|
|
403
472
|
|
|
404
473
|
return html`
|
|
405
|
-
<${Modal}
|
|
474
|
+
<${Modal}
|
|
475
|
+
title=${isNew ? "New Resource" : `Edit: ${entry.name}`}
|
|
476
|
+
onClose=${onClose}
|
|
477
|
+
unsavedChanges=${changeCount}
|
|
478
|
+
onSaveBeforeClose=${() => handleSave({ closeAfterSave: true })}
|
|
479
|
+
onDiscardBeforeClose=${() => {
|
|
480
|
+
resetToBaseline();
|
|
481
|
+
return true;
|
|
482
|
+
}}
|
|
483
|
+
activeOperationLabel=${loading ? "Save/Delete request is still running" : ""}
|
|
484
|
+
>
|
|
406
485
|
<div class="library-editor">
|
|
407
486
|
${isNew && html`
|
|
408
487
|
<label>
|
|
@@ -459,10 +538,27 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
|
|
|
459
538
|
`}
|
|
460
539
|
<div style="flex:1" />
|
|
461
540
|
<button class="btn-ghost" onClick=${onClose}>Cancel</button>
|
|
462
|
-
<button
|
|
541
|
+
<button
|
|
542
|
+
class="btn-primary"
|
|
543
|
+
onClick=${() => {
|
|
544
|
+
void handleSave({ closeAfterSave: true });
|
|
545
|
+
}}
|
|
546
|
+
disabled=${loading}
|
|
547
|
+
>
|
|
463
548
|
${loading ? html`<${Spinner} size=${14} />` : (isNew ? "Create" : "Save")}
|
|
464
549
|
</button>
|
|
465
550
|
</div>
|
|
551
|
+
<${SaveDiscardBar}
|
|
552
|
+
dirty=${hasUnsaved}
|
|
553
|
+
message=${`You have unsaved changes (${changeCount})`}
|
|
554
|
+
saveLabel=${isNew ? "Create" : "Save Changes"}
|
|
555
|
+
discardLabel="Discard"
|
|
556
|
+
onSave=${() => {
|
|
557
|
+
void handleSave({ closeAfterSave: false });
|
|
558
|
+
}}
|
|
559
|
+
onDiscard=${resetToBaseline}
|
|
560
|
+
saving=${loading}
|
|
561
|
+
/>
|
|
466
562
|
</div>
|
|
467
563
|
${confirmDelete && html`
|
|
468
564
|
<${ConfirmDialog}
|
|
@@ -664,7 +760,7 @@ export function LibraryTab() {
|
|
|
664
760
|
${iconText(":refresh: Rebuild")}
|
|
665
761
|
</button>
|
|
666
762
|
<button class="library-type-pill active" onClick=${() => setEditing({})}>
|
|
667
|
-
|
|
763
|
+
${iconText("➕ New")}
|
|
668
764
|
</button>
|
|
669
765
|
</div>
|
|
670
766
|
|
|
@@ -704,7 +800,7 @@ export function LibraryTab() {
|
|
|
704
800
|
: "Create your first prompt, agent profile, or skill."}
|
|
705
801
|
action=${searchQuery.value
|
|
706
802
|
? { label: "Clear search", onClick: () => { searchQuery.value = ""; loadEntries(); } }
|
|
707
|
-
: { label: "
|
|
803
|
+
: { label: "➕ New Resource", onClick: () => setEditing({}) }} />
|
|
708
804
|
`}
|
|
709
805
|
|
|
710
806
|
${!loading && displayed.length > 0 && html`
|
package/ui/tabs/settings.js
CHANGED
|
@@ -59,6 +59,17 @@ import {
|
|
|
59
59
|
|
|
60
60
|
/* ─── Scoped Styles ─── */
|
|
61
61
|
const SETTINGS_STYLES = `
|
|
62
|
+
/* Category navigation */
|
|
63
|
+
.settings-category-mobile {
|
|
64
|
+
display: none;
|
|
65
|
+
margin-bottom: 10px;
|
|
66
|
+
}
|
|
67
|
+
.settings-category-mobile-label {
|
|
68
|
+
display: block;
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
color: var(--text-tertiary, #8a8a8a);
|
|
71
|
+
margin: 0 0 6px 2px;
|
|
72
|
+
}
|
|
62
73
|
/* Category pill tabs — horizontal scrollable row */
|
|
63
74
|
.settings-category-tabs {
|
|
64
75
|
display: flex;
|
|
@@ -111,9 +122,9 @@ const SETTINGS_STYLES = `
|
|
|
111
122
|
flex-wrap: wrap;
|
|
112
123
|
row-gap: 8px;
|
|
113
124
|
padding: 10px 16px;
|
|
114
|
-
min-width: 240px;
|
|
125
|
+
min-width: min(240px, calc(100vw - 24px));
|
|
115
126
|
max-width: 480px;
|
|
116
|
-
width:
|
|
127
|
+
width: min(480px, calc(100vw - 24px));
|
|
117
128
|
background: var(--glass-bg, rgba(30,30,46,0.95));
|
|
118
129
|
backdrop-filter: blur(20px);
|
|
119
130
|
-webkit-backdrop-filter: blur(20px);
|
|
@@ -340,6 +351,10 @@ const SETTINGS_STYLES = `
|
|
|
340
351
|
color: var(--accent, #5a7cff);
|
|
341
352
|
}
|
|
342
353
|
.settings-banner-text { flex: 1; }
|
|
354
|
+
.settings-banner code {
|
|
355
|
+
overflow-wrap: anywhere;
|
|
356
|
+
word-break: break-word;
|
|
357
|
+
}
|
|
343
358
|
/* Diff display for confirm dialog */
|
|
344
359
|
.settings-diff {
|
|
345
360
|
max-height: 300px;
|
|
@@ -388,6 +403,18 @@ const SETTINGS_STYLES = `
|
|
|
388
403
|
width: 100%;
|
|
389
404
|
box-sizing: border-box;
|
|
390
405
|
padding-bottom: 80px;
|
|
406
|
+
overflow-x: clip;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
.setting-row .segmented-control {
|
|
410
|
+
display: flex;
|
|
411
|
+
width: 100%;
|
|
412
|
+
flex-wrap: wrap;
|
|
413
|
+
margin-bottom: 0;
|
|
414
|
+
}
|
|
415
|
+
.setting-row .segmented-btn {
|
|
416
|
+
flex: 1 1 96px;
|
|
417
|
+
min-width: 0;
|
|
391
418
|
}
|
|
392
419
|
|
|
393
420
|
body.settings-save-open .main-content {
|
|
@@ -455,6 +482,78 @@ body.settings-save-open .main-content {
|
|
|
455
482
|
color: var(--text-tertiary, #666);
|
|
456
483
|
text-align: center;
|
|
457
484
|
}
|
|
485
|
+
|
|
486
|
+
@media (max-width: 900px) {
|
|
487
|
+
.settings-category-mobile {
|
|
488
|
+
display: block;
|
|
489
|
+
}
|
|
490
|
+
.settings-category-tabs {
|
|
491
|
+
display: grid;
|
|
492
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
493
|
+
gap: 8px;
|
|
494
|
+
overflow-x: visible;
|
|
495
|
+
padding: 4px 0 8px;
|
|
496
|
+
}
|
|
497
|
+
.settings-category-tab {
|
|
498
|
+
width: 100%;
|
|
499
|
+
border-radius: 12px;
|
|
500
|
+
min-height: 42px;
|
|
501
|
+
padding: 10px 12px;
|
|
502
|
+
justify-content: flex-start;
|
|
503
|
+
white-space: normal;
|
|
504
|
+
line-height: 1.25;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
@media (max-width: 700px) {
|
|
509
|
+
.settings-save-bar {
|
|
510
|
+
left: 12px;
|
|
511
|
+
right: 12px;
|
|
512
|
+
width: auto;
|
|
513
|
+
max-width: none;
|
|
514
|
+
transform: none;
|
|
515
|
+
padding: 10px 12px;
|
|
516
|
+
}
|
|
517
|
+
@keyframes slideUp {
|
|
518
|
+
from { transform: translateY(20px); opacity: 0; }
|
|
519
|
+
to { transform: translateY(0); opacity: 1; }
|
|
520
|
+
}
|
|
521
|
+
.settings-save-bar .save-bar-actions {
|
|
522
|
+
width: 100%;
|
|
523
|
+
justify-content: flex-end;
|
|
524
|
+
}
|
|
525
|
+
.setting-input-wrap {
|
|
526
|
+
flex-direction: column;
|
|
527
|
+
align-items: stretch;
|
|
528
|
+
gap: 6px;
|
|
529
|
+
}
|
|
530
|
+
.setting-input-wrap input[type="text"],
|
|
531
|
+
.setting-input-wrap input[type="number"],
|
|
532
|
+
.setting-input-wrap input[type="password"],
|
|
533
|
+
.setting-input-wrap textarea,
|
|
534
|
+
.setting-input-wrap select {
|
|
535
|
+
width: 100%;
|
|
536
|
+
flex: 1 1 auto;
|
|
537
|
+
}
|
|
538
|
+
.setting-unit {
|
|
539
|
+
align-self: flex-end;
|
|
540
|
+
}
|
|
541
|
+
.setting-help-tooltip {
|
|
542
|
+
left: 0;
|
|
543
|
+
transform: none;
|
|
544
|
+
min-width: 0;
|
|
545
|
+
max-width: min(92vw, 360px);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
@media (max-width: 640px) {
|
|
550
|
+
.settings-category-tabs {
|
|
551
|
+
display: none;
|
|
552
|
+
}
|
|
553
|
+
.setting-row .segmented-btn {
|
|
554
|
+
flex: 1 1 calc(50% - 4px);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
458
557
|
`;
|
|
459
558
|
|
|
460
559
|
/* ─── Inject styles once ─── */
|
|
@@ -1172,6 +1271,21 @@ function ServerConfigMode() {
|
|
|
1172
1271
|
const activeCat = CATEGORIES.find((c) => c.id === activeCategory);
|
|
1173
1272
|
|
|
1174
1273
|
return html`
|
|
1274
|
+
<div class="settings-category-mobile">
|
|
1275
|
+
<label class="settings-category-mobile-label">Category</label>
|
|
1276
|
+
<div class="setting-input-wrap">
|
|
1277
|
+
<select
|
|
1278
|
+
value=${activeCategory}
|
|
1279
|
+
onChange=${(e) => {
|
|
1280
|
+
setActiveCategory(e.target.value);
|
|
1281
|
+
haptic("light");
|
|
1282
|
+
}}
|
|
1283
|
+
>
|
|
1284
|
+
${CATEGORIES.map((cat) => html`<option key=${cat.id} value=${cat.id}>${cat.label}</option>`)}
|
|
1285
|
+
</select>
|
|
1286
|
+
</div>
|
|
1287
|
+
</div>
|
|
1288
|
+
|
|
1175
1289
|
<!-- Category tabs -->
|
|
1176
1290
|
<div class="settings-category-tabs">
|
|
1177
1291
|
${CATEGORIES.map(
|