Skip to content

Commit c3beb1e

Browse files
kingpanther13claude
andcommitted
feat: preserve group open state, add per-group master toggle
- Persist open groups in a Set that survives re-renders (fixes collapse-on-toggle-click bug where clicking any tool toggle would call render() and wipe the expanded state) - Add master enable/disable toggle per group in the header - Master toggle affects all non-mandatory, non-feature-gated tools - Stop propagation on master toggle so clicking it doesn't also expand/collapse the group Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ad46b8a commit c3beb1e

1 file changed

Lines changed: 55 additions & 10 deletions

File tree

src/ha_mcp/settings_ui.py

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,15 @@ def apply_tool_visibility(
262262
.group { background: var(--surface); border-radius: 12px; margin-bottom: 8px;
263263
overflow: hidden; border: 1px solid var(--border); }
264264
.group-header { display: flex; align-items: center; justify-content: space-between;
265-
padding: 12px 16px; cursor: pointer; user-select: none; }
265+
padding: 12px 16px; cursor: pointer; user-select: none; gap: 12px; }
266266
.group-header:hover { background: var(--surface-hover); }
267+
.group-header-left { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
267268
.group-name { font-weight: 600; font-size: 0.95rem; }
268-
.group-count { font-size: 0.8rem; color: var(--text-secondary); margin-left: 8px; }
269-
.group-chevron { transition: transform 0.2s; color: var(--text-secondary); }
269+
.group-count { font-size: 0.8rem; color: var(--text-secondary); }
270+
.group-chevron { transition: transform 0.2s; color: var(--text-secondary);
271+
display: inline-block; width: 12px; }
270272
.group-chevron.open { transform: rotate(90deg); }
273+
.group-master { flex-shrink: 0; }
271274
.group-tools { display: none; border-top: 1px solid var(--border); }
272275
.group-tools.open { display: block; }
273276
.tool { display: flex; align-items: center; justify-content: space-between;
@@ -328,6 +331,7 @@ def apply_tool_visibility(
328331
let toolData = [];
329332
let toolStates = {};
330333
let saveTimer = null;
334+
let openGroups = new Set();
331335
332336
async function loadTools() {
333337
const resp = await fetch('./api/settings/tools');
@@ -364,21 +368,62 @@ def apply_tool_visibility(
364368
const group = document.createElement('div');
365369
group.className = 'group';
366370
371+
// Per-group toggle state: enabled if ANY non-mandatory/non-gated tool is enabled
372+
const toggleable = tools.filter(t => !MANDATORY.includes(t.name) && !t.disabled_by);
373+
const anyEnabled = toggleable.some(t => getState(t.name) !== 'disabled');
374+
const groupEnabled = tools.filter(t => {
375+
const s = getState(t.name);
376+
return MANDATORY.includes(t.name) || (!t.disabled_by && s !== 'disabled');
377+
}).length;
378+
367379
const header = document.createElement('div');
368380
header.className = 'group-header';
369-
const groupEnabled = tools.filter(t => getState(t.name) !== 'disabled').length;
370-
header.innerHTML = `<div><span class="group-name">${tag}</span>` +
371-
`<span class="group-count">${groupEnabled}/${tools.length} enabled</span></div>` +
372-
`<span class="group-chevron">&#9654;</span>`;
373-
header.onclick = () => {
381+
header.innerHTML = `<div class="group-header-left">` +
382+
`<span class="group-chevron">&#9654;</span>` +
383+
`<span class="group-name">${tag}</span>` +
384+
`<span class="group-count">${groupEnabled}/${tools.length} enabled</span>` +
385+
`</div>` +
386+
`<label class="switch group-master" title="Enable/disable all tools in this group">` +
387+
`<input type="checkbox" ${anyEnabled ? 'checked' : ''} ${toggleable.length === 0 ? 'disabled' : ''}>` +
388+
`<span class="slider"></span>` +
389+
`</label>`;
390+
391+
const chevron = header.querySelector('.group-chevron');
392+
const masterInput = header.querySelector('.group-master input');
393+
394+
header.addEventListener('click', (e) => {
395+
// Ignore clicks on the master toggle itself
396+
if (e.target.closest('.group-master')) return;
397+
if (openGroups.has(tag)) openGroups.delete(tag);
398+
else openGroups.add(tag);
374399
const toolsDiv = group.querySelector('.group-tools');
375-
const chevron = header.querySelector('.group-chevron');
376400
toolsDiv.classList.toggle('open');
377401
chevron.classList.toggle('open');
378-
};
402+
});
403+
404+
if (masterInput) {
405+
masterInput.addEventListener('click', (e) => e.stopPropagation());
406+
masterInput.addEventListener('change', (e) => {
407+
const target = e.target.checked ? 'enabled' : 'disabled';
408+
toggleable.forEach(t => {
409+
if (target === 'enabled') {
410+
// Restore to pinned if it was pinned by default, else enabled
411+
toolStates[t.name] = DEFAULT_PINNED.includes(t.name) ? 'pinned' : 'enabled';
412+
} else {
413+
toolStates[t.name] = 'disabled';
414+
}
415+
});
416+
scheduleSave();
417+
render();
418+
});
419+
}
379420
380421
const toolsDiv = document.createElement('div');
381422
toolsDiv.className = 'group-tools';
423+
if (openGroups.has(tag)) {
424+
toolsDiv.classList.add('open');
425+
chevron.classList.add('open');
426+
}
382427
383428
tools.forEach(t => {
384429
const state = getState(t.name);

0 commit comments

Comments
 (0)