refactor(frontend): migrate dashboard tabbar to createTabStrip + drop overflow (#1464 step 1)
The dashboard's tab strip now runs on the shared createTabStrip (@hive/shared/tabs.js) — the third and final consumer of #1464 step 1 (after logs + the agent window selector). - activateTab is reduced to the per-tab side-effects only (body.dataset.activeTab, the selection-bar gate, the lazy-loads); createTabStrip owns the active tab/pane toggle + aria-selected + hash routing, calling activateTab via onShow. Panes switch via the `hidden` attribute (was a `.tab-pane-active` class) — the markup carries `hidden` so there's no flash before the script runs. - the responsive overflow menu is DROPPED per the design call (move panes to their own pages rather than hide them in a dropdown). Removes ~150 lines: updateTabbarOverflow, closeOverflowMenu, the click/keydown handlers, the ResizeObserver + MutationObserver, and syncTabFromHash. S3TT1NGS is a regular strip tab now (no data-overflow). - CSS: .tab.active becomes .tab.hive-tab--active; pane visibility is now .tab-pane[hidden] { display: none }; the overflow dropdown styles are gone. Count pills (setTabCount/refreshTabCounts) are unchanged. Behaviour-preserving except the deliberate overflow removal; aria-selected is now standardised on the tabs.
This commit is contained in:
parent
0680f87d3d
commit
2ec06f07ed
3 changed files with 37 additions and 320 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
makePathLink, appendText, appendLinkified,
|
||||
openStream, renderServerWarnings,
|
||||
} from './common.js';
|
||||
import { createTabStrip } from '@hive/shared/tabs.js';
|
||||
|
||||
// mdNode (in common.js) reads `window.marked` for the markdown side
|
||||
// panel preview path. Set it here on the dashboard entry so file
|
||||
|
|
@ -4057,28 +4058,20 @@ window.marked = marked;
|
|||
// (`/flow.html`) reached via the tab-strip link. Tab routing only
|
||||
// applies when the tab DOM is present (e.g. not on the flow page
|
||||
// itself, where these elements don't exist and the loop no-ops).
|
||||
const TABS = ['swarm', 'call', 'system', 'permissions', 'schedules', 'stats', 'settings'];
|
||||
function activateTab(name) {
|
||||
const target = TABS.includes(name) ? name : TABS[0];
|
||||
for (const t of TABS) {
|
||||
const tab = $('tab-' + t);
|
||||
const pane = $('tab-pane-' + t);
|
||||
if (tab) tab.classList.toggle('active', t === target);
|
||||
if (pane) pane.classList.toggle('tab-pane-active', t === target);
|
||||
}
|
||||
// The shared hash-routed tab strip (@hive/shared/tabs.js) owns the
|
||||
// tab/pane toggle + aria-selected + routing; `activateTab` runs the
|
||||
// per-tab side-effects via the strip's onShow. Constructed below (after
|
||||
// the lazy-load fns it calls are defined). The responsive overflow ⋮
|
||||
// menu was dropped — if the strip ever runs out of room we move panes
|
||||
// to their own pages rather than hide them in a dropdown.
|
||||
function activateTab(target) {
|
||||
// Track active tab on the body so renderSelectionBar can gate
|
||||
// visibility (bar only belongs on SW4RM where agent cards live).
|
||||
// Re-render the bar so the toggle takes effect immediately
|
||||
// on hashchange without waiting for the next SSE update.
|
||||
document.body.dataset.activeTab = target;
|
||||
renderSelectionBar(Array.from(containersState.values()));
|
||||
// Keep overflow button active state in sync after tab change.
|
||||
updateTabbarOverflow();
|
||||
// Re-fetch schedules on activation as a safety net (SSE covers
|
||||
// live mutations but re-sync ensures consistency after disconnect
|
||||
// windows or approval-path inserts that don't yet emit). Also
|
||||
// re-fetch reminders on SCH3DUL3S activation since both sections
|
||||
// live on the same tab.
|
||||
// Re-fetch on activation as a safety net: SSE covers live mutations,
|
||||
// re-sync covers disconnect windows / approval-path inserts that
|
||||
// don't yet emit. SCH3DUL3S also re-fetches reminders (same tab).
|
||||
if (target === 'schedules') { refreshSchedules(); refreshReminders(); }
|
||||
// Permissions tables: SSE covers worker-applied changes
|
||||
// (capabilities_changed / tool_groups_changed); re-fetch on
|
||||
|
|
@ -4095,25 +4088,6 @@ window.marked = marked;
|
|||
// open (cpu needs a short two-sample read each refresh).
|
||||
if (target === 'system') { startContainerLoadPolling(); } else { stopContainerLoadPolling(); }
|
||||
}
|
||||
// ─── tabbar overflow menu ────────────────────────────────────────────────
|
||||
// Tabs with `data-overflow="default"` (LOGS, SETTINGS) always live in
|
||||
// the ⋮ dropdown. When the bar is too narrow to show all remaining tabs,
|
||||
// rightmost tabs spill into the dropdown too (right-to-left).
|
||||
//
|
||||
// Implementation: all tabs stay in the DOM. The ⋮ wrapper sits at the
|
||||
// flex end. `tab-overflowed` hides a tab from the bar (display:none).
|
||||
// The dropdown is rebuilt from scratch on every call — it holds cloned
|
||||
// <a>/<button> items, not the originals.
|
||||
//
|
||||
// IMPORTANT: these must be declared before syncTabFromHash() is called
|
||||
// below — activateTab() calls updateTabbarOverflow() which closes over
|
||||
// these variables, and const/let are not accessible before their
|
||||
// declaration (TDZ).
|
||||
|
||||
const overflowWrap = $('tabbar-overflow');
|
||||
const overflowBtn = $('tabbar-overflow-btn');
|
||||
const overflowDrop = $('tabbar-overflow-dropdown');
|
||||
let overflowOpen = false;
|
||||
|
||||
// ─── ST4TS: hive-wide turn-stats rollup ──────────────────────────────────
|
||||
// Pull-only (no SSE): fetched from /api/stats-hive on tab activation and
|
||||
|
|
@ -4372,158 +4346,12 @@ window.marked = marked;
|
|||
if (containerLoadTimer) { clearInterval(containerLoadTimer); containerLoadTimer = null; }
|
||||
}
|
||||
|
||||
function syncTabFromHash() {
|
||||
const h = (window.location.hash || '#swarm').replace(/^#/, '');
|
||||
activateTab(h);
|
||||
}
|
||||
window.addEventListener('hashchange', () => {
|
||||
syncTabFromHash();
|
||||
updateTabbarOverflow();
|
||||
});
|
||||
syncTabFromHash();
|
||||
// Wire the shared tab strip now that activateTab + the lazy-load fns it
|
||||
// calls are defined. The strip resolves the active tab from the hash
|
||||
// (default SW4RM), toggles the active tab/pane + aria-selected, and
|
||||
// fires activateTab for the per-tab side-effects on every change.
|
||||
createTabStrip($('tabbar'), { defaultId: 'swarm', onShow: activateTab });
|
||||
|
||||
function closeOverflowMenu() {
|
||||
if (!overflowOpen) return;
|
||||
overflowOpen = false;
|
||||
if (overflowDrop) overflowDrop.hidden = true;
|
||||
if (overflowBtn) overflowBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
if (overflowBtn) {
|
||||
overflowBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
overflowOpen = !overflowOpen;
|
||||
overflowDrop.hidden = !overflowOpen;
|
||||
overflowBtn.setAttribute('aria-expanded', String(overflowOpen));
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (overflowOpen && !overflowWrap?.contains(e.target)) closeOverflowMenu();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && overflowOpen) { closeOverflowMenu(); e.stopImmediatePropagation(); }
|
||||
}, true);
|
||||
|
||||
function updateTabbarOverflow() {
|
||||
const tabbar = $('tabbar');
|
||||
if (!tabbar || !overflowBtn || !overflowDrop) return;
|
||||
// Skip dropdown rebuild while it is open — the 1s badge tick would
|
||||
// replace DOM nodes and cause a flicker mid-interaction.
|
||||
if (overflowOpen) return;
|
||||
|
||||
// Collect all tabs that are not JS-hidden (P33RS/M4TR1X may be hidden
|
||||
// by feature-gating) and not already removed from the DOM.
|
||||
const allTabs = [...tabbar.querySelectorAll('.tab')];
|
||||
|
||||
// Separate default-overflow tabs from dynamic ones.
|
||||
const defaultOverflow = allTabs.filter(t => t.dataset.overflow === 'default');
|
||||
const dynamic = allTabs.filter(t => t.dataset.overflow !== 'default' && !t.hidden);
|
||||
|
||||
// Step 1: un-overflow all dynamic tabs so we can measure natural widths.
|
||||
dynamic.forEach(t => t.classList.remove('tab-overflowed'));
|
||||
|
||||
// Step 2: compute the right boundary where visible tabs must end.
|
||||
// getBoundingClientRect is used instead of clientWidth because
|
||||
// clientWidth includes the tabbar's horizontal padding (~2em total),
|
||||
// causing an over-allocation of ~2em; flex gap between tabs is also
|
||||
// not captured by offsetWidth accumulation. Together these pushed
|
||||
// the ⋮ button off the right edge of the screen.
|
||||
const tabbarRect = tabbar.getBoundingClientRect();
|
||||
const padR = parseFloat(getComputedStyle(tabbar).paddingRight) || 0;
|
||||
// Right edge of the flex content area (inside right padding).
|
||||
// Fall back to clientWidth-based estimate when the rect is zero
|
||||
// (element not in layout, e.g. display:none ancestor).
|
||||
const contentRight = tabbarRect.width > 0
|
||||
? (tabbarRect.right - padR)
|
||||
: (tabbar.clientWidth - padR);
|
||||
// Space to reserve for the overflow wrapper. Use its actual offsetWidth
|
||||
// when available; fall back to 40px on the first call (button hidden).
|
||||
const btnReserve = (overflowWrap.offsetWidth || 40) + 4;
|
||||
const cutoffRight = contentRight - btnReserve;
|
||||
|
||||
// Step 3: any tab whose right edge exceeds the cutoff is overflowed,
|
||||
// along with all subsequent tabs (keeps the visible set contiguous
|
||||
// and left-anchored). Once the first offending tab is found, all
|
||||
// following tabs are also overflowed without re-measuring.
|
||||
const dynamicOverflow = [];
|
||||
for (const tab of dynamic) {
|
||||
if (dynamicOverflow.length > 0 || tab.getBoundingClientRect().right > cutoffRight) {
|
||||
dynamicOverflow.push(tab);
|
||||
}
|
||||
}
|
||||
dynamicOverflow.forEach(t => t.classList.add('tab-overflowed'));
|
||||
|
||||
// Step 4: rebuild the dropdown from the two overflow sets.
|
||||
const overflowedTabs = [...dynamicOverflow, ...defaultOverflow];
|
||||
overflowDrop.replaceChildren();
|
||||
for (const tab of overflowedTabs) {
|
||||
const li = document.createElement('li');
|
||||
li.setAttribute('role', 'presentation');
|
||||
// Build a menu item that mirrors the tab's link/button behaviour.
|
||||
const href = tab.getAttribute('href');
|
||||
const item = href
|
||||
? document.createElement('a')
|
||||
: document.createElement('button');
|
||||
if (href) {
|
||||
item.href = href;
|
||||
} else {
|
||||
item.type = 'button';
|
||||
item.addEventListener('click', () => {
|
||||
closeOverflowMenu();
|
||||
window.location.hash = tab.dataset.tab || '';
|
||||
});
|
||||
}
|
||||
item.className = 'tabbar-overflow-item';
|
||||
item.setAttribute('role', 'menuitem');
|
||||
// Copy the tab label text.
|
||||
const labelEl = tab.querySelector('.tab-label');
|
||||
item.textContent = labelEl ? labelEl.textContent : (tab.textContent || '').trim();
|
||||
// Active state: hash tabs match current route.
|
||||
if (tab.dataset.tab && tab.classList.contains('active')) {
|
||||
item.classList.add('tabbar-overflow-item-active');
|
||||
}
|
||||
// Count badge: copy from the tab's count pill if non-zero.
|
||||
const countEl = tab.querySelector('.tab-count');
|
||||
if (countEl && !countEl.hidden && countEl.textContent) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tabbar-overflow-badge' + (countEl.classList.contains('tab-count-attn') ? ' tab-count-attn' : '');
|
||||
badge.textContent = countEl.textContent;
|
||||
item.append(badge);
|
||||
}
|
||||
if (href) {
|
||||
item.addEventListener('click', closeOverflowMenu);
|
||||
}
|
||||
li.append(item);
|
||||
overflowDrop.append(li);
|
||||
}
|
||||
|
||||
// Step 5: show/hide the overflow button.
|
||||
const hasItems = overflowedTabs.length > 0;
|
||||
overflowBtn.hidden = !hasItems;
|
||||
if (!hasItems) closeOverflowMenu();
|
||||
|
||||
// Step 6: mark the button active when the current tab is overflowed.
|
||||
const activeTab = document.body.dataset.activeTab;
|
||||
const activeIsOverflowed = overflowedTabs.some(t => t.dataset.tab === activeTab);
|
||||
overflowBtn.classList.toggle('tabbar-overflow-active', activeIsOverflowed);
|
||||
}
|
||||
|
||||
// Call on init and whenever the bar width changes.
|
||||
updateTabbarOverflow();
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(() => updateTabbarOverflow());
|
||||
const tabbar = $('tabbar');
|
||||
if (tabbar) ro.observe(tabbar);
|
||||
}
|
||||
// Also refresh after P33RS/M4TR1X visibility changes (they're toggled by
|
||||
// refreshState). A MutationObserver on the tabbar catches attribute changes
|
||||
// (hidden attr) on child tabs without coupling to specific render paths.
|
||||
const tabbarEl = $('tabbar');
|
||||
if (tabbarEl && typeof MutationObserver !== 'undefined') {
|
||||
new MutationObserver(() => updateTabbarOverflow()).observe(tabbarEl, {
|
||||
attributes: true, subtree: true, attributeFilter: ['hidden'],
|
||||
});
|
||||
}
|
||||
|
||||
// Tab count pills — pure derived data from the existing state
|
||||
// stores so SSE-driven updates flow through without extra plumbing.
|
||||
|
|
@ -4638,9 +4466,6 @@ window.marked = marked;
|
|||
// target (whole-schedule cancellation or all-targets-cancelled
|
||||
// means "not waiting on the worker"; those don't pull attention).
|
||||
setTabCount('schedules', activeScheduleCount());
|
||||
|
||||
// Re-render overflow dropdown so badges stay in sync.
|
||||
updateTabbarOverflow();
|
||||
}
|
||||
// Poll the state stores on a 1s tick to keep the pill counts in
|
||||
// sync. The state stores are mutated synchronously by every SSE
|
||||
|
|
|
|||
Loading…
Reference in a new issue