Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
167 lines
6.6 KiB
JavaScript
167 lines
6.6 KiB
JavaScript
// ==========================================
|
|
// Chat Surface — ToolsPopup Component
|
|
// ==========================================
|
|
// Scrollable popup anchored to a toolbar icon.
|
|
// Category-based tool toggles with persistence to localStorage.
|
|
|
|
const html = window.html;
|
|
const { useState, useEffect, useRef, useCallback, useMemo } = window.hooks;
|
|
|
|
const STORAGE_KEY = 'sw-disabled-tools';
|
|
|
|
/**
|
|
* Load tools list from API and organize by category.
|
|
* @returns {{ categories: Array, disabled: Set, toggle: Function, getDisabled: Function }}
|
|
*/
|
|
export function useTools() {
|
|
const [tools, setTools] = useState([]);
|
|
const [disabled, setDisabled] = useState(_loadDisabled);
|
|
|
|
useEffect(() => {
|
|
// Load available tools from API
|
|
if (window.sw?.api?.tools?.list) {
|
|
window.sw.api.tools.list().then(resp => {
|
|
setTools(resp || []);
|
|
}).catch(() => {});
|
|
}
|
|
}, []);
|
|
|
|
const categories = useMemo(() => {
|
|
const map = {};
|
|
for (const t of tools) {
|
|
const cat = t.category || 'Other';
|
|
if (!map[cat]) map[cat] = [];
|
|
map[cat].push(t);
|
|
}
|
|
return Object.entries(map).map(([name, items]) => ({ name, items }));
|
|
}, [tools]);
|
|
|
|
const toggle = useCallback((toolName) => {
|
|
setDisabled(prev => {
|
|
const next = new Set(prev);
|
|
if (next.has(toolName)) next.delete(toolName);
|
|
else next.add(toolName);
|
|
_saveDisabled(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const toggleCategory = useCallback((catName) => {
|
|
setDisabled(prev => {
|
|
const cat = categories.find(c => c.name === catName);
|
|
if (!cat) return prev;
|
|
const next = new Set(prev);
|
|
const allDisabled = cat.items.every(t => next.has(t.name));
|
|
for (const t of cat.items) {
|
|
if (allDisabled) next.delete(t.name);
|
|
else next.add(t.name);
|
|
}
|
|
_saveDisabled(next);
|
|
return next;
|
|
});
|
|
}, [categories]);
|
|
|
|
const getDisabled = useCallback(() => [...disabled], [disabled]);
|
|
|
|
return { categories, disabled, toggle, toggleCategory, getDisabled };
|
|
}
|
|
|
|
/**
|
|
* @param {{
|
|
* open: boolean,
|
|
* onClose: () => void,
|
|
* categories: Array<{name: string, items: Array<{name: string, description?: string}>}>,
|
|
* disabled: Set<string>,
|
|
* onToggle: (name: string) => void,
|
|
* onToggleCategory: (catName: string) => void,
|
|
* }} props
|
|
*/
|
|
export function ToolsPopup({ open, onClose, categories, disabled, onToggle, onToggleCategory }) {
|
|
const panelRef = useRef(null);
|
|
const [expandedCats, setExpandedCats] = useState({});
|
|
|
|
// Close on Escape or outside click
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
function _onKey(e) {
|
|
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
|
|
}
|
|
function _onClick(e) {
|
|
if (panelRef.current && !panelRef.current.contains(e.target)) onClose();
|
|
}
|
|
document.addEventListener('keydown', _onKey, true);
|
|
setTimeout(() => document.addEventListener('click', _onClick), 0);
|
|
return () => {
|
|
document.removeEventListener('keydown', _onKey, true);
|
|
document.removeEventListener('click', _onClick);
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
const _toggleCat = useCallback((name) => {
|
|
setExpandedCats(prev => ({ ...prev, [name]: !prev[name] }));
|
|
}, []);
|
|
|
|
if (!open) return null;
|
|
|
|
const disabledCount = disabled.size;
|
|
|
|
return html`
|
|
<div class="sw-tools-popup" ref=${panelRef} role="dialog" aria-label="Tools toggle">
|
|
<div class="sw-tools-popup__header">
|
|
<span class="sw-tools-popup__title">Tools</span>
|
|
${disabledCount > 0 && html`
|
|
<span class="sw-tools-popup__badge">${disabledCount} disabled</span>`}
|
|
</div>
|
|
<div class="sw-tools-popup__body">
|
|
${categories.length === 0 && html`
|
|
<div class="sw-tools-popup__empty">No tools available</div>`}
|
|
${categories.map(cat => {
|
|
const allDisabled = cat.items.every(t => disabled.has(t.name));
|
|
const someDisabled = cat.items.some(t => disabled.has(t.name));
|
|
const isExpanded = expandedCats[cat.name] !== false; // default open
|
|
|
|
return html`
|
|
<div key=${cat.name} class="sw-tools-popup__category">
|
|
<div class="sw-tools-popup__cat-header">
|
|
<button class="sw-tools-popup__cat-toggle"
|
|
onClick=${() => _toggleCat(cat.name)}
|
|
aria-expanded=${isExpanded}>
|
|
<span class=${'sw-tools-popup__chevron' + (isExpanded ? ' sw-tools-popup__chevron--open' : '')}>
|
|
\u25B6
|
|
</span>
|
|
<span>${cat.name}</span>
|
|
<span class="sw-tools-popup__cat-count">${cat.items.length}</span>
|
|
</button>
|
|
<label class="sw-tools-popup__cat-switch">
|
|
<input type="checkbox"
|
|
checked=${!allDisabled}
|
|
indeterminate=${someDisabled && !allDisabled}
|
|
onChange=${() => onToggleCategory(cat.name)} />
|
|
</label>
|
|
</div>
|
|
${isExpanded && cat.items.map(tool => html`
|
|
<label key=${tool.name} class="sw-tools-popup__tool">
|
|
<input type="checkbox"
|
|
checked=${!disabled.has(tool.name)}
|
|
onChange=${() => onToggle(tool.name)} />
|
|
<span class="sw-tools-popup__tool-name">${tool.name}</span>
|
|
${tool.description && html`
|
|
<span class="sw-tools-popup__tool-desc">${tool.description}</span>`}
|
|
</label>`)}
|
|
</div>`;
|
|
})}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function _loadDisabled() {
|
|
try {
|
|
const arr = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
|
return new Set(arr || []);
|
|
} catch (_) { return new Set(); }
|
|
}
|
|
|
|
function _saveDisabled(set) {
|
|
try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...set])); } catch (_) {}
|
|
}
|