From 769621a009ae5161a82d0c593f92a6f0f9e58006 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Thu, 16 Apr 2026 08:58:05 -0700 Subject: [PATCH] feat: add flyout menu to sidebar session items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add data-session-key attribute to buildSidebarHTML article element so openFlyoutMenu() can read session info via closest('[data-session-key]') - Add tile-options-btn button (with aria-label and aria-haspopup) inside sidebar-item-header — reuses the same class as tile version so the existing delegated document click handler picks it up automatically - Guard renderSidebar click handler against .tile-options-btn clicks so clicking ⋮ doesn't also trigger openSession() - Add .sidebar-item-header .tile-options-btn CSS: compact 20×20px, subtle opacity-based visibility to fit the narrower sidebar layout - Tests: buildSidebarHTML data-session-key, tile-options-btn presence/aria, renderSidebar click guard, CSS sidebar btn styling --- muxplex/frontend/app.js | 4 +- muxplex/frontend/style.css | 18 +++++++ muxplex/frontend/tests/test_app.mjs | 33 ++++++++++++ muxplex/tests/test_frontend_css.py | 19 +++++++ muxplex/tests/test_frontend_js.py | 79 +++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 1 deletion(-) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index f23b55c..13669f9 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -596,10 +596,11 @@ function buildSidebarHTML(session, currentSession) { // which is not a registered remote_instance, so routing through federation would 404. var _sidebarEffRemoteId = session.remoteId != null ? String(session.remoteId) : ''; return ( - `
` + + `
` + `` + `` + `
` @@ -738,6 +739,7 @@ function renderSidebar(sessions, currentSession) { const name = item.dataset.session; const remoteId = item.dataset.remoteId || ''; on(item, 'click', (e) => { + if (e.target.closest && e.target.closest('.tile-options-btn')) return; if (name !== currentSession) openSession(name, { remoteId }); }); }); diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index f53ef2a..beb8c9a 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -658,6 +658,24 @@ body { flex-shrink: 0; } +.sidebar-item-header .tile-options-btn { + width: 20px; + height: 20px; + font-size: 12px; + flex-shrink: 0; + background: transparent; + border-color: transparent; + opacity: 0.6; +} + +.sidebar-item:hover .sidebar-item-header .tile-options-btn, +.sidebar-item-header .tile-options-btn:hover, +.sidebar-item-header .tile-options-btn:focus-visible { + opacity: 1; + background: var(--bg-surface); + border-color: var(--border-subtle); +} + .sidebar-empty { padding: 16px 12px; color: var(--text-muted); diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 55c619a..af2b979 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -1717,6 +1717,39 @@ test('buildSidebarHTML includes snapshot preview in a pre element', () => { assert.ok(/
[\s\S]*line1[\s\S]*<\/pre>/.test(html), 'snapshot content should be inside a 
 element');
 });
 
+// --- buildSidebarHTML flyout menu button ---
+
+test('buildSidebarHTML article element has data-session-key attribute', () => {
+  const session = { name: 'my-session', sessionKey: 'key::my-session', snapshot: '', bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  assert.ok(html.includes('data-session-key='), 'article must have data-session-key attribute');
+  assert.ok(html.includes('data-session-key="key::my-session"'), 'data-session-key must use sessionKey value');
+});
+
+test('buildSidebarHTML article element uses session name as data-session-key when sessionKey is absent', () => {
+  const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  assert.ok(html.includes('data-session-key="my-session"'), 'data-session-key must fall back to session.name when sessionKey absent');
+});
+
+test('buildSidebarHTML header contains a tile-options-btn button', () => {
+  const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  assert.ok(html.includes('tile-options-btn'), 'sidebar item header must include tile-options-btn button');
+});
+
+test('buildSidebarHTML tile-options-btn has aria-label="Session options"', () => {
+  const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  assert.ok(html.includes('aria-label="Session options"'), 'tile-options-btn must have aria-label="Session options"');
+});
+
+test('buildSidebarHTML tile-options-btn has aria-haspopup="true"', () => {
+  const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
+  const html = app.buildSidebarHTML(session, '');
+  assert.ok(html.includes('aria-haspopup="true"'), 'tile-options-btn must have aria-haspopup="true"');
+});
+
 // --- renderSidebar ---
 
 test('renderSidebar populates sidebar-list innerHTML when viewMode is fullscreen', () => {
diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py
index 529b8d4..cec5610 100644
--- a/muxplex/tests/test_frontend_css.py
+++ b/muxplex/tests/test_frontend_css.py
@@ -2362,3 +2362,22 @@ def test_device_badge_height_matches_flyout_button() -> None:
         ".device-badge must have increased height to match the ~24px ⋮ button — "
         "add min-height or increase padding/line-height so the badge is ~22-24px tall"
     )
+
+
+# ── Sidebar flyout menu (feat: add flyout menu to sidebar session items) ──────
+
+
+def test_css_sidebar_options_btn_styled() -> None:
+    """CSS must contain a rule targeting .tile-options-btn inside .sidebar-item-header or .sidebar-item."""
+    css = read_css()
+    # Look for a CSS rule that targets the options btn specifically in the sidebar context
+    has_sidebar_btn_style = (
+        ".sidebar-item-header .tile-options-btn" in css
+        or ".sidebar-item .tile-options-btn" in css
+        or ".sidebar-item-header > .tile-options-btn" in css
+    )
+    assert has_sidebar_btn_style, (
+        "CSS must include a rule targeting .tile-options-btn inside .sidebar-item-header "
+        "or .sidebar-item to ensure the button fits the sidebar layout "
+        "(smaller and compact for the narrower sidebar width)"
+    )
diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py
index 2506cea..9b2a722 100644
--- a/muxplex/tests/test_frontend_js.py
+++ b/muxplex/tests/test_frontend_js.py
@@ -4632,3 +4632,82 @@ def test_domcontentloaded_calls_render_view_dropdown_after_restore() -> None:
         "DOMContentLoaded must call renderViewDropdown() after restoreState() so the "
         "dropdown label correctly reflects _activeView on page reload"
     )
+
+
+# ── Sidebar flyout menu (feat: add flyout menu to sidebar session items) ──────
+
+
+def test_build_sidebar_html_article_has_data_session_key() -> None:
+    """buildSidebarHTML must include data-session-key on the article element."""
+    match = re.search(
+        r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
+        _JS,
+        re.DOTALL,
+    )
+    assert match, "buildSidebarHTML function not found"
+    body = match.group(1)
+    assert "data-session-key" in body, (
+        "buildSidebarHTML must include data-session-key attribute on the article element "
+        "so openFlyoutMenu can look up session data via closest('[data-session-key]')"
+    )
+
+
+def test_build_sidebar_html_has_tile_options_btn() -> None:
+    """buildSidebarHTML must include a .tile-options-btn button in the header."""
+    match = re.search(
+        r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
+        _JS,
+        re.DOTALL,
+    )
+    assert match, "buildSidebarHTML function not found"
+    body = match.group(1)
+    assert "tile-options-btn" in body, (
+        "buildSidebarHTML must include a button with class 'tile-options-btn' "
+        "in the sidebar item header so users can access the flyout menu"
+    )
+
+
+def test_build_sidebar_html_options_btn_has_aria_label() -> None:
+    """buildSidebarHTML tile-options-btn must have aria-label='Session options'."""
+    match = re.search(
+        r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
+        _JS,
+        re.DOTALL,
+    )
+    assert match, "buildSidebarHTML function not found"
+    body = match.group(1)
+    assert 'aria-label="Session options"' in body or "aria-label='Session options'" in body, (
+        "buildSidebarHTML tile-options-btn must have aria-label='Session options' "
+        "for accessibility (same as tile version)"
+    )
+
+
+def test_build_sidebar_html_options_btn_has_aria_haspopup() -> None:
+    """buildSidebarHTML tile-options-btn must have aria-haspopup='true'."""
+    match = re.search(
+        r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
+        _JS,
+        re.DOTALL,
+    )
+    assert match, "buildSidebarHTML function not found"
+    body = match.group(1)
+    assert 'aria-haspopup="true"' in body or "aria-haspopup='true'" in body, (
+        "buildSidebarHTML tile-options-btn must have aria-haspopup='true' "
+        "for accessibility (same as tile version)"
+    )
+
+
+def test_render_sidebar_click_handler_guards_tile_options_btn() -> None:
+    """renderSidebar click handler must guard against tile-options-btn clicks."""
+    match = re.search(
+        r"function renderSidebar\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
+        _JS,
+        re.DOTALL,
+    )
+    assert match, "renderSidebar function not found"
+    body = match.group(1)
+    assert "tile-options-btn" in body, (
+        "renderSidebar click handler must guard against .tile-options-btn clicks "
+        "so clicking ⋮ doesn't also trigger openSession() — "
+        "use: if (e.target.closest('.tile-options-btn')) return;"
+    )