Files
muxplex/docs/plans/2026-04-15-views-phase2-implementation.md
T

66 KiB
Raw Blame History

Views Feature — Phase 2: Views Backend Logic + Header Dropdown UI

Execution: Use the subagent-driven-development workflow to implement this plan.

Goal: Wire up the views filtering/switching logic and build the primary view-switching dropdown UI, so users can create, switch between, and manage views from the header.

Architecture: The frontend's getVisibleSessions() is rewritten to honor the active_view state field — filtering sessions to the current view's membership list (user view), everything not hidden ("All"), or everything hidden ("Hidden"). Views are created/renamed/reordered/deleted via PATCH /api/settings (views lives in settings.json). The active_view is persisted per-device via PATCH /api/state. A header dropdown provides the view switcher with keyboard shortcuts, inline "New View" creation, and a link to a new "Views" tab in the settings dialog for rename/reorder/delete management.

Tech Stack: Python 3.12+ / FastAPI / pytest + pytest-asyncio / vanilla JS / CSS

Design reference: docs/plans/2026-04-15-views-design.md Phase 1 reference: docs/plans/2026-04-15-views-phase1-implementation.md

Assumes Phase 1 is complete: identity.py exists, session keys use device_id:name, federation endpoints use device_id lookup, views is in DEFAULT_SETTINGS (empty array) and SYNCABLE_KEYS, active_view is in state schema (default "all"), views.py has enforce_mutual_exclusion() and validate_view_name(), gridViewMode no longer accepts "filtered" in settings.


Task 1: Add active_view to StatePatch Model

Files:

  • Modify: muxplex/main.py (lines ~425502)
  • Modify: muxplex/tests/test_api.py

The backend PATCH /api/state endpoint does not yet accept active_view. The StatePatch Pydantic model needs a new optional field, and the patch_state handler needs to write it through.

Step 1: Write the failing test

Add to muxplex/tests/test_api.py, after the existing state tests:

# ---------------------------------------------------------------------------
# active_view in PATCH /api/state
# ---------------------------------------------------------------------------


def test_patch_state_sets_active_view(client, tmp_path, monkeypatch):
    """PATCH /api/state with active_view persists the value."""
    import muxplex.state as state_mod

    monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path / "state")
    monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state" / "state.json")

    response = client.patch("/api/state", json={"active_view": "Work Project"})
    assert response.status_code == 200
    data = response.json()
    assert data["active_view"] == "Work Project"

    # Verify persistence
    response2 = client.get("/api/state")
    assert response2.json()["active_view"] == "Work Project"


def test_patch_state_active_view_defaults_to_all(client, tmp_path, monkeypatch):
    """GET /api/state returns active_view='all' by default."""
    import muxplex.state as state_mod

    monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path / "state")
    monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state" / "state.json")

    response = client.get("/api/state")
    assert response.status_code == 200
    assert response.json()["active_view"] == "all"

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_api.py::test_patch_state_sets_active_view -v

Expected: FAIL — active_view not accepted by StatePatch

Step 3: Apply the changes to muxplex/main.py

Change 1 — Add active_view to the StatePatch model (line ~425):

class StatePatch(BaseModel):
    session_order: list[str] | None = None
    active_session: str | None = None
    active_remote_id: str | None = None
    active_view: str | None = None

Change 2 — Add active_view handling to patch_state (line ~492, after the active_remote_id block):

        if "active_view" in changed:
            state["active_view"] = patch.active_view

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_api.py::test_patch_state_sets_active_view muxplex/tests/test_api.py::test_patch_state_active_view_defaults_to_all -v

Expected: Both PASS

Step 5: Commit

cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add active_view to StatePatch model for PATCH /api/state"

Task 2: Rewrite getVisibleSessions() to Honor Active View

Files:

  • Modify: muxplex/frontend/app.js (lines ~537547)
  • Modify: muxplex/tests/test_frontend_js.py

The current getVisibleSessions() only filters out hidden sessions. It must now also filter by the active view: "All" shows everything not hidden, "Hidden" shows only hidden sessions, and a user view shows only sessions whose sessionKey is in that view's sessions list (plus status tiles).

Step 1: Write the failing tests

Add to muxplex/tests/test_frontend_js.py:

# ---------------------------------------------------------------------------
# getVisibleSessions respects active view
# ---------------------------------------------------------------------------


def test_get_visible_sessions_all_view_excludes_hidden() -> None:
    """In 'all' view, getVisibleSessions excludes hidden sessions."""
    assert "function getVisibleSessions" in _JS
    # Check that the function references _activeView or active_view
    # to determine filtering behavior
    assert "_activeView" in _JS, (
        "getVisibleSessions must reference _activeView state variable"
    )


def test_active_view_state_variable_exists() -> None:
    """app.js must declare an _activeView state variable."""
    assert re.search(r"let\s+_activeView\s*=\s*'all'", _JS) or \
           re.search(r"var\s+_activeView\s*=\s*'all'", _JS), (
        "_activeView state variable must be declared with default 'all'"
    )


def test_get_visible_sessions_user_view_filters_by_session_key() -> None:
    """getVisibleSessions must check view sessions list using sessionKey."""
    assert "sessionKey" in _JS.split("function getVisibleSessions")[1].split("function ")[0], (
        "getVisibleSessions must use sessionKey for view membership check"
    )


def test_get_visible_sessions_hidden_view_shows_hidden() -> None:
    """In 'hidden' view, getVisibleSessions shows only hidden sessions."""
    # The hidden view logic must invert the filter
    fn_body = _JS.split("function getVisibleSessions")[1].split("function ")[0]
    assert "'hidden'" in fn_body or '"hidden"' in fn_body, (
        "getVisibleSessions must handle the 'hidden' view case"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_active_view_state_variable_exists -v

Expected: FAIL — _activeView does not exist yet

Step 3: Apply the changes to muxplex/frontend/app.js

Change 1 — Add the _activeView state variable in the "App state" section (after _activeFilterDevice on line 139):

let _activeView = 'all';

Change 2 — Rewrite getVisibleSessions() (replace lines 537547):

/**
 * Returns sessions filtered by the active view.
 * - "all": everything not in hidden_sessions (default)
 * - "hidden": only sessions in hidden_sessions
 * - user view name: only sessions whose sessionKey is in that view's sessions list
 * Status entries (unreachable, auth_failed, empty) are always excluded here —
 * they are rendered separately as status tiles by renderGrid().
 * @param {object[]} sessions
 * @returns {object[]}
 */
function getVisibleSessions(sessions) {
  var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
  var all = (sessions || []).filter(function(s) { return !s.status; });

  if (_activeView === 'hidden') {
    // Show only hidden sessions
    return all.filter(function(s) {
      var key = s.sessionKey || s.name;
      return hidden.includes(key) || hidden.includes(s.name);
    });
  }

  if (_activeView === 'all') {
    // Show everything NOT hidden
    return all.filter(function(s) {
      var key = s.sessionKey || s.name;
      return !(hidden.length > 0 && (hidden.includes(key) || hidden.includes(s.name)));
    });
  }

  // User view: find the view by name, filter to its session list
  var views = (_serverSettings && _serverSettings.views) || [];
  var activeViewObj = null;
  for (var i = 0; i < views.length; i++) {
    if (views[i].name === _activeView) {
      activeViewObj = views[i];
      break;
    }
  }

  if (!activeViewObj) {
    // View no longer exists — fall back to "all"
    _activeView = 'all';
    return all.filter(function(s) {
      var key = s.sessionKey || s.name;
      return !(hidden.length > 0 && (hidden.includes(key) || hidden.includes(s.name)));
    });
  }

  var viewSessions = activeViewObj.sessions || [];
  return all.filter(function(s) {
    var key = s.sessionKey || s.name;
    return viewSessions.includes(key);
  });
}

Change 3 — Add test helpers at the bottom of app.js, in the test-only section (before the window.MuxplexApp export):

/** Test-only: get _activeView. */
function _getActiveView() {
  return _activeView;
}

/** Test-only: set _activeView directly. */
function _setActiveView(view) {
  _activeView = view;
}

Change 4 — Export the new test helpers in window.MuxplexApp (add after _setActiveFilterDevice):

    _getActiveView,
    _setActiveView,

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "active_view" -v

Expected: All new tests PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: rewrite getVisibleSessions to filter by active view"

Task 3: Auto-Add Session to Active View on Creation

Files:

  • Modify: muxplex/frontend/app.js (the createNewSession() function, line ~2089)
  • Modify: muxplex/tests/test_frontend_js.py

When a new session is created via the + button while a user view is active, auto-add the new session's key to that view's sessions list. Only for sessions created through the UI, not those appearing from federation polls.

Step 1: Write the failing test

Add to muxplex/tests/test_frontend_js.py:

def test_create_new_session_references_active_view() -> None:
    """createNewSession must check _activeView to auto-add to the active user view."""
    fn_body = _JS.split("async function createNewSession")[1].split("\nasync function ")[0]
    assert "_activeView" in fn_body, (
        "createNewSession must reference _activeView for auto-add behavior"
    )

Step 2: Run test to verify it fails

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_create_new_session_references_active_view -v

Expected: FAIL — _activeView not referenced in createNewSession

Step 3: Apply the change to muxplex/frontend/app.js

In createNewSession() (line ~2089), after the successful api('POST', endpoint, { name }) call and before polling, add the auto-add logic. Insert after const sessionName = data.name || name; (line ~2095):

    // Auto-add to active user view (not "all" or "hidden")
    if (_activeView !== 'all' && _activeView !== 'hidden') {
      var views = (_serverSettings && _serverSettings.views) || [];
      var viewIdx = -1;
      for (var vi = 0; vi < views.length; vi++) {
        if (views[vi].name === _activeView) { viewIdx = vi; break; }
      }
      if (viewIdx >= 0) {
        // Build the sessionKey for the new session
        var newSessionKey = remoteId ? (remoteId + ':' + sessionName) : sessionName;
        // If the server already gave us a deviceId-based key, prefer that
        if (!remoteId && _serverSettings && _serverSettings.device_id) {
          newSessionKey = _serverSettings.device_id + ':' + sessionName;
        }
        var updatedViews = JSON.parse(JSON.stringify(views));
        if (!updatedViews[viewIdx].sessions.includes(newSessionKey)) {
          updatedViews[viewIdx].sessions.push(newSessionKey);
          api('PATCH', '/api/settings', { views: updatedViews }).catch(function(err) {
            console.warn('[createNewSession] auto-add to view failed:', err);
          });
          if (_serverSettings) _serverSettings.views = updatedViews;
        }
      }
    }

Step 4: Run test to verify it passes

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_create_new_session_references_active_view -v

Expected: PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: auto-add new sessions to active user view on creation"

Task 4: Header Dropdown — HTML Structure

Files:

  • Modify: muxplex/frontend/index.html (lines 2028, the <header class="app-header"> section)
  • Modify: muxplex/tests/test_frontend_html.py

Add the view dropdown trigger element and the dropdown container to the header, between the wordmark and the header-actions div.

Step 1: Write the failing test

Add to muxplex/tests/test_frontend_html.py:

def test_view_dropdown_trigger_exists() -> None:
    """The header must contain a view-dropdown trigger button."""
    assert 'id="view-dropdown-trigger"' in _HTML, (
        "index.html must contain a #view-dropdown-trigger element"
    )


def test_view_dropdown_container_exists() -> None:
    """The header must contain a view-dropdown-menu container."""
    assert 'id="view-dropdown-menu"' in _HTML, (
        "index.html must contain a #view-dropdown-menu element"
    )


def test_view_dropdown_trigger_has_aria() -> None:
    """The view dropdown trigger must have aria-haspopup and aria-expanded."""
    assert 'aria-haspopup="true"' in _HTML.split('view-dropdown-trigger')[1][:300], (
        "view-dropdown-trigger must have aria-haspopup='true'"
    )
    assert 'aria-expanded="false"' in _HTML.split('view-dropdown-trigger')[1][:300], (
        "view-dropdown-trigger must have aria-expanded='false'"
    )


def test_view_dropdown_menu_has_role_menu() -> None:
    """The dropdown menu must have role='menu'."""
    assert 'role="menu"' in _HTML.split('view-dropdown-menu')[1][:300], (
        "view-dropdown-menu must have role='menu'"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_view_dropdown_trigger_exists -v

Expected: FAIL — view-dropdown-trigger not in HTML

Step 3: Apply the changes to muxplex/frontend/index.html

Replace the header section (lines 2028) with:

    <header class="app-header">
      <h1 class="app-wordmark"><img src="/wordmark-on-dark.svg" alt="muxplex" height="24" /></h1>
      <div class="view-dropdown" id="view-dropdown">
        <button id="view-dropdown-trigger" class="view-dropdown__trigger" aria-haspopup="true" aria-expanded="false" aria-controls="view-dropdown-menu">
          <span id="view-dropdown-label">All Sessions</span>
          <span class="view-dropdown__caret" aria-hidden="true">&#9662;</span>
        </button>
        <div id="view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
      </div>
      <div class="header-actions">
        <button id="new-session-btn" class="header-btn" aria-label="New session">+</button>
        <button id="view-mode-btn" class="header-btn" aria-label="Toggle view mode" title="View: auto">&#9638;</button>
        <button id="settings-btn" class="header-btn" aria-label="Settings">&#9881;</button>
        <span id="connection-status"></span>
      </div>
    </header>

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "view_dropdown" -v

Expected: All 4 new tests PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/index.html muxplex/tests/test_frontend_html.py && git commit -m "feat: add view dropdown HTML structure to header"

Task 5: Header Dropdown — CSS Styles

Files:

  • Modify: muxplex/frontend/style.css
  • Modify: muxplex/tests/test_frontend_css.py

Style the view dropdown trigger, menu, and items. The dropdown is absolutely positioned below the trigger.

Step 1: Write the failing test

Add to muxplex/tests/test_frontend_css.py:

def test_view_dropdown_trigger_styled() -> None:
    """style.css must contain .view-dropdown__trigger styles."""
    assert ".view-dropdown__trigger" in _CSS, (
        "style.css must style .view-dropdown__trigger"
    )


def test_view_dropdown_menu_styled() -> None:
    """style.css must contain .view-dropdown__menu styles."""
    assert ".view-dropdown__menu" in _CSS, (
        "style.css must style .view-dropdown__menu"
    )


def test_view_dropdown_item_styled() -> None:
    """style.css must contain .view-dropdown__item styles."""
    assert ".view-dropdown__item" in _CSS, (
        "style.css must style .view-dropdown__item"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py::test_view_dropdown_trigger_styled -v

Expected: FAIL — .view-dropdown__trigger not in CSS

Step 3: Add styles to muxplex/frontend/style.css

Add before the /* Filter bar */ section (before line ~1645). Insert after the header styles:

/* ── View Dropdown ─────────────────────────────────────────────────── */

.view-dropdown {
  position: relative;
  display: flex;
  align-items: center;
  margin-left: 12px;
}

.view-dropdown__trigger {
  display: flex;
  align-items: center;
  gap: 4px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: 6px;
  padding: 4px 8px;
  color: var(--text);
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
  transition: border-color 0.15s, background 0.15s;
}

.view-dropdown__trigger:hover,
.view-dropdown__trigger[aria-expanded="true"] {
  border-color: var(--border);
  background: var(--bg-surface);
}

.view-dropdown__caret {
  font-size: 10px;
  color: var(--text-muted);
}

.view-dropdown__menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  min-width: 200px;
  max-width: 280px;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 4px 0;
  z-index: 100;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}

.view-dropdown__item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 6px 12px;
  background: transparent;
  border: none;
  color: var(--text);
  font-size: 13px;
  cursor: pointer;
  text-align: left;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.view-dropdown__item:hover,
.view-dropdown__item:focus-visible {
  background: var(--bg-surface);
}

.view-dropdown__item--active {
  color: var(--accent);
}

.view-dropdown__item--active::before {
  content: "\2713";
  width: 14px;
  flex-shrink: 0;
}

.view-dropdown__item:not(.view-dropdown__item--active)::before {
  content: "";
  width: 14px;
  flex-shrink: 0;
}

.view-dropdown__shortcut {
  margin-left: auto;
  font-size: 11px;
  color: var(--text-dim);
  font-family: monospace;
}

.view-dropdown__separator {
  height: 1px;
  margin: 4px 0;
  background: var(--border-subtle);
}

.view-dropdown__action {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 6px 12px;
  background: transparent;
  border: none;
  color: var(--text-muted);
  font-size: 13px;
  cursor: pointer;
  text-align: left;
}

.view-dropdown__action:hover {
  background: var(--bg-surface);
  color: var(--text);
}

.view-dropdown__new-input {
  width: calc(100% - 24px);
  margin: 4px 12px;
  padding: 4px 8px;
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--text);
  font-size: 13px;
  outline: none;
}

.view-dropdown__count {
  font-size: 11px;
  color: var(--text-dim);
}

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py -k "view_dropdown" -v

Expected: All 3 new tests PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/style.css muxplex/tests/test_frontend_css.py && git commit -m "feat: add view dropdown CSS styles"

Task 6: Header Dropdown — JS Render + Open/Close + View Switching

Files:

  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/tests/test_frontend_js.py

Implement renderViewDropdown() which populates the dropdown menu with: All Sessions, user views, Hidden (count), a separator, + New View, and Manage Views. Implement open/close toggle, click-outside dismiss, and view switching that updates active_view via PATCH /api/state and re-renders the grid.

Step 1: Write the failing tests

Add to muxplex/tests/test_frontend_js.py:

def test_render_view_dropdown_function_exists() -> None:
    """app.js must define a renderViewDropdown function."""
    assert "function renderViewDropdown" in _JS, (
        "app.js must contain a renderViewDropdown function"
    )


def test_render_view_dropdown_exported() -> None:
    """renderViewDropdown must be exported on window.MuxplexApp."""
    assert "renderViewDropdown" in _JS.split("window.MuxplexApp")[1], (
        "renderViewDropdown must be exported on window.MuxplexApp"
    )


def test_toggle_view_dropdown_function_exists() -> None:
    """app.js must define a toggleViewDropdown function."""
    assert "function toggleViewDropdown" in _JS, (
        "app.js must contain a toggleViewDropdown function"
    )


def test_switch_view_function_exists() -> None:
    """app.js must define a switchView function."""
    assert "function switchView" in _JS, (
        "app.js must contain a switchView function"
    )


def test_switch_view_patches_state() -> None:
    """switchView must PATCH /api/state with the new active_view."""
    fn_body = _JS.split("function switchView")[1].split("\nfunction ")[0]
    assert "active_view" in fn_body, (
        "switchView must include active_view in the state PATCH"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_view_dropdown_function_exists -v

Expected: FAIL — function does not exist

Step 3: Add the functions to muxplex/frontend/app.js

Insert after the renderFilterBar() function (after line ~752), before renderGrid():

// ── View Dropdown ─────────────────────────────────────────────────────────

/**
 * Render the view dropdown menu contents.
 * Populates #view-dropdown-menu with: All Sessions, user views (in array order),
 * Hidden (count), separator, + New View, Manage Views.
 */
function renderViewDropdown() {
  var menu = $('view-dropdown-menu');
  if (!menu) return;

  var views = (_serverSettings && _serverSettings.views) || [];
  var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
  var hiddenCount = hidden.length;

  var html = '';

  // "All Sessions" — always first, shortcut: 1
  var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
  html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">';
  html += 'All Sessions';
  html += '<span class="view-dropdown__shortcut">1</span>';
  html += '</button>';

  // Separator before user views (if any)
  if (views.length > 0) {
    html += '<div class="view-dropdown__separator" role="separator"></div>';
  }

  // User views — in array order, shortcuts 28
  for (var i = 0; i < views.length; i++) {
    var vActive = _activeView === views[i].name ? ' view-dropdown__item--active' : '';
    html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(views[i].name) + '">';
    html += escapeHtml(views[i].name);
    if (i < 7) {
      html += '<span class="view-dropdown__shortcut">' + (i + 2) + '</span>';
    }
    html += '</button>';
  }

  // Separator before Hidden
  html += '<div class="view-dropdown__separator" role="separator"></div>';

  // "Hidden (N)" — always last system view, shortcut: 9
  var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
  html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">';
  html += 'Hidden';
  if (hiddenCount > 0) html += ' <span class="view-dropdown__count">(' + hiddenCount + ')</span>';
  html += '<span class="view-dropdown__shortcut">9</span>';
  html += '</button>';

  // Separator before actions
  html += '<div class="view-dropdown__separator" role="separator"></div>';

  // "+ New View" action
  html += '<button class="view-dropdown__action" role="menuitem" data-action="new-view">+ New View</button>';

  // "Manage Views..." action
  html += '<button class="view-dropdown__action" role="menuitem" data-action="manage-views">Manage Views\u2026</button>';

  menu.innerHTML = html;

  // Update the trigger label
  var label = $('view-dropdown-label');
  if (label) {
    if (_activeView === 'all') label.textContent = 'All Sessions';
    else if (_activeView === 'hidden') label.textContent = 'Hidden';
    else label.textContent = _activeView;
  }
}

/**
 * Toggle the view dropdown open/closed.
 */
function toggleViewDropdown() {
  var menu = $('view-dropdown-menu');
  var trigger = $('view-dropdown-trigger');
  if (!menu) return;

  var isOpen = !menu.classList.contains('hidden');
  if (isOpen) {
    closeViewDropdown();
  } else {
    renderViewDropdown();
    menu.classList.remove('hidden');
    if (trigger) trigger.setAttribute('aria-expanded', 'true');
  }
}

/**
 * Close the view dropdown.
 */
function closeViewDropdown() {
  var menu = $('view-dropdown-menu');
  var trigger = $('view-dropdown-trigger');
  if (menu) menu.classList.add('hidden');
  if (trigger) trigger.setAttribute('aria-expanded', 'false');
  // Remove any inline "New View" input
  var existingInput = menu && menu.querySelector('.view-dropdown__new-input');
  if (existingInput) existingInput.remove();
}

/**
 * Switch to a different view by name.
 * Updates _activeView, persists via PATCH /api/state, updates the dropdown label,
 * closes the dropdown, and re-renders the grid.
 * @param {string} viewName - "all", "hidden", or a user view name
 */
function switchView(viewName) {
  _activeView = viewName;
  closeViewDropdown();
  renderGrid(_currentSessions || []);
  renderSidebar(_currentSessions || [], _viewingSession);

  // Update dropdown label immediately
  var label = $('view-dropdown-label');
  if (label) {
    if (viewName === 'all') label.textContent = 'All Sessions';
    else if (viewName === 'hidden') label.textContent = 'Hidden';
    else label.textContent = viewName;
  }

  // Persist to server state (fire-and-forget)
  api('PATCH', '/api/state', { active_view: viewName }).catch(function(err) {
    console.warn('[switchView] failed to persist active_view:', err);
  });
}

Change 2 — Add the new functions to the window.MuxplexApp export (after renderFilterBar,):

    // View dropdown
    renderViewDropdown,
    toggleViewDropdown,
    closeViewDropdown,
    switchView,

Change 3 — Bind event listeners in bindStaticEventListeners(). Add after the settings dialog bindings section (after line ~2226):

  // View dropdown
  on($('view-dropdown-trigger'), 'click', toggleViewDropdown);

  // View dropdown — delegated click handler for menu items
  var viewDropdownMenu = $('view-dropdown-menu');
  if (viewDropdownMenu) {
    viewDropdownMenu.addEventListener('click', function(e) {
      var item = e.target.closest('[data-view]');
      if (item) {
        switchView(item.dataset.view);
        return;
      }
      var action = e.target.closest('[data-action]');
      if (action) {
        if (action.dataset.action === 'new-view') {
          showNewViewInput();
        } else if (action.dataset.action === 'manage-views') {
          closeViewDropdown();
          openSettings();
          switchSettingsTab('views');
        }
      }
    });
  }

  // Click-outside dismiss for view dropdown
  document.addEventListener('click', function(e) {
    var dropdown = $('view-dropdown');
    if (!dropdown) return;
    var menu = $('view-dropdown-menu');
    if (menu && !menu.classList.contains('hidden') && !dropdown.contains(e.target)) {
      closeViewDropdown();
    }
  });

Change 4 — Restore _activeView from server state in restoreState() (line ~216). After const state = await res.json(); and before the if (state.active_session) block, add:

    // Restore active_view from server state
    if (state.active_view) {
      _activeView = state.active_view;
      renderViewDropdown();
    }

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "view_dropdown or switch_view or toggle_view" -v

Expected: All 5 new tests PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: implement view dropdown render, toggle, and view switching"

Task 7: Keyboard Shortcuts — Backtick, Number Keys, Arrow Navigation

Files:

  • Modify: muxplex/frontend/app.js (the handleGlobalKeydown() function, line ~1812)
  • Modify: muxplex/tests/test_frontend_js.py

Backtick opens/closes the dropdown on the grid page only (not in fullscreen). Number keys 19 switch views directly. Arrow keys + Enter navigate within the open dropdown.

Step 1: Write the failing tests

Add to muxplex/tests/test_frontend_js.py:

def test_handle_global_keydown_has_backtick_handler() -> None:
    """handleGlobalKeydown must handle the backtick key for view dropdown."""
    fn_body = _JS.split("function handleGlobalKeydown")[1].split("\nfunction ")[0]
    assert "`" in fn_body or "Backquote" in fn_body or "backtick" in fn_body.lower(), (
        "handleGlobalKeydown must handle the backtick key"
    )


def test_handle_global_keydown_has_number_key_shortcuts() -> None:
    """handleGlobalKeydown must handle number keys 1-9 for view switching."""
    fn_body = _JS.split("function handleGlobalKeydown")[1].split("\nfunction ")[0]
    assert "switchView" in fn_body, (
        "handleGlobalKeydown must call switchView for number key shortcuts"
    )


def test_backtick_only_on_grid_not_fullscreen() -> None:
    """Backtick shortcut must check that _viewMode is 'grid' (not fullscreen)."""
    fn_body = _JS.split("function handleGlobalKeydown")[1].split("\nfunction ")[0]
    # Must guard backtick handler to grid mode only
    assert "_viewMode" in fn_body, (
        "handleGlobalKeydown backtick handler must check _viewMode"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_handle_global_keydown_has_backtick_handler -v

Expected: FAIL — backtick handling not in handleGlobalKeydown

Step 3: Apply the changes to muxplex/frontend/app.js

Rewrite handleGlobalKeydown() (replace lines ~18121830):

/**
 * Global keydown handler.
 * If settings are open: Escape closes settings.
 * Comma key (not in inputs) opens settings.
 * Backtick opens/closes view dropdown (grid overview only).
 * Number keys 1-9 switch views directly (grid overview only).
 * Arrow keys navigate within the open dropdown.
 * In fullscreen: Escape returns to grid.
 * @param {KeyboardEvent} e
 */
function handleGlobalKeydown(e) {
  if (_settingsOpen) {
    if (e.key === 'Escape') {
      closeSettings();
    }
    return;
  }

  // Ignore shortcuts when typing in an input/textarea/select
  var tag = document.activeElement && document.activeElement.tagName;
  var inInput = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';

  if (e.key === ',' && !e.ctrlKey && !e.metaKey && !inInput) {
    openSettings();
    return;
  }

  // View dropdown shortcuts — grid overview only, not fullscreen (backtick is a real character)
  if (_viewMode === 'grid' && !inInput && !e.ctrlKey && !e.metaKey) {
    // Backtick toggles dropdown
    if (e.key === '`') {
      e.preventDefault();
      toggleViewDropdown();
      return;
    }

    // Number keys 1-9 switch views directly
    var num = parseInt(e.key, 10);
    if (num >= 1 && num <= 9) {
      var views = (_serverSettings && _serverSettings.views) || [];
      if (num === 1) {
        switchView('all');
      } else if (num === 9) {
        switchView('hidden');
      } else if (num - 2 < views.length) {
        switchView(views[num - 2].name);
      }
      return;
    }
  }

  // Arrow key navigation within open dropdown
  var dropdownMenu = $('view-dropdown-menu');
  if (dropdownMenu && !dropdownMenu.classList.contains('hidden')) {
    var items = dropdownMenu.querySelectorAll('[role="menuitem"]');
    if (items.length > 0) {
      var current = dropdownMenu.querySelector('[role="menuitem"]:focus');
      var idx = Array.prototype.indexOf.call(items, current);

      if (e.key === 'ArrowDown') {
        e.preventDefault();
        var next = idx < items.length - 1 ? idx + 1 : 0;
        items[next].focus();
        return;
      }
      if (e.key === 'ArrowUp') {
        e.preventDefault();
        var prev = idx > 0 ? idx - 1 : items.length - 1;
        items[prev].focus();
        return;
      }
      if (e.key === 'Enter' && current) {
        current.click();
        return;
      }
    }
    if (e.key === 'Escape') {
      closeViewDropdown();
      return;
    }
  }

  if (_viewMode === 'fullscreen' && e.key === 'Escape') {
    e.preventDefault();
    closeSession();
  }
}

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "backtick or number_key" -v

Expected: All 3 new tests PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add backtick and number key shortcuts for view dropdown"

Task 8: "New View" Inline Creation Flow

Files:

  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/tests/test_frontend_js.py

Clicking "+ New View" in the dropdown shows an inline text input. Enter creates the view via PATCH /api/settings, closes the dropdown, and switches to the new view.

Step 1: Write the failing test

Add to muxplex/tests/test_frontend_js.py:

def test_show_new_view_input_function_exists() -> None:
    """app.js must define a showNewViewInput function."""
    assert "function showNewViewInput" in _JS, (
        "app.js must contain a showNewViewInput function"
    )


def test_show_new_view_input_patches_settings() -> None:
    """showNewViewInput handler must PATCH /api/settings with the new view."""
    fn_body = _JS.split("function showNewViewInput")[1].split("\nfunction ")[0]
    assert "views" in fn_body and "PATCH" in fn_body, (
        "showNewViewInput must PATCH /api/settings with updated views"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_show_new_view_input_function_exists -v

Expected: FAIL — function does not exist

Step 3: Add the function to muxplex/frontend/app.js

Insert after closeViewDropdown():

/**
 * Show an inline text input in the view dropdown for creating a new view.
 * Enter creates the view, Escape cancels.
 */
function showNewViewInput() {
  var menu = $('view-dropdown-menu');
  if (!menu) return;

  // Remove existing input if any
  var existing = menu.querySelector('.view-dropdown__new-input');
  if (existing) { existing.focus(); return; }

  // Hide the "+ New View" action button
  var newBtn = menu.querySelector('[data-action="new-view"]');

  var input = document.createElement('input');
  input.type = 'text';
  input.className = 'view-dropdown__new-input';
  input.placeholder = 'View name';
  input.maxLength = 30;
  input.setAttribute('aria-label', 'New view name');

  // Insert before the "Manage Views" action
  var manageBtn = menu.querySelector('[data-action="manage-views"]');
  if (newBtn) newBtn.replaceWith(input);
  else if (manageBtn) menu.insertBefore(input, manageBtn);
  else menu.appendChild(input);

  input.focus();

  input.addEventListener('keydown', function(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      var name = input.value.trim();
      if (!name) return;

      // Validate: check reserved names and duplicates
      var lower = name.toLowerCase();
      if (lower === 'all' || lower === 'hidden') {
        showToast('"' + name + '" is a reserved name');
        return;
      }
      var views = (_serverSettings && _serverSettings.views) || [];
      for (var i = 0; i < views.length; i++) {
        if (views[i].name === name) {
          showToast('A view named "' + name + '" already exists');
          return;
        }
      }

      // Create the view
      var updatedViews = views.concat([{ name: name, sessions: [] }]);
      api('PATCH', '/api/settings', { views: updatedViews })
        .then(function() {
          if (_serverSettings) _serverSettings.views = updatedViews;
          switchView(name);
        })
        .catch(function(err) {
          showToast('Failed to create view');
          console.warn('[showNewViewInput] PATCH failed:', err);
        });
    } else if (e.key === 'Escape') {
      closeViewDropdown();
    }
  });

  input.addEventListener('blur', function() {
    // Small delay to allow click-on-manage-views to fire first
    setTimeout(function() {
      if (document.activeElement !== input) {
        closeViewDropdown();
      }
    }, 150);
  });
}

Add showNewViewInput to the window.MuxplexApp export (after switchView,):

    showNewViewInput,

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "new_view_input" -v

Expected: Both PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: add inline New View creation in dropdown"

Task 9: "Manage Views" Settings Tab — HTML + JS

Files:

  • Modify: muxplex/frontend/index.html (add Views tab button and panel)
  • Modify: muxplex/frontend/app.js (add renderViewsSettingsTab())
  • Modify: muxplex/tests/test_frontend_html.py
  • Modify: muxplex/tests/test_frontend_js.py

Add a new "Views" tab in the settings dialog with: list of user views, inline rename (click to edit), up/down arrow buttons for reorder, delete with inline "Sure? [Yes] [No]" confirmation.

Step 1: Write the failing tests

Add to muxplex/tests/test_frontend_html.py:

def test_settings_has_views_tab_button() -> None:
    """Settings dialog must have a 'Views' tab button."""
    assert 'data-tab="views"' in _HTML, (
        "Settings dialog must have a tab button with data-tab='views'"
    )


def test_settings_has_views_panel() -> None:
    """Settings dialog must have a views panel."""
    assert 'class="settings-panel' in _HTML and 'data-tab="views"' in _HTML, (
        "Settings dialog must have a settings-panel with data-tab='views'"
    )

Add to muxplex/tests/test_frontend_js.py:

def test_render_views_settings_tab_function_exists() -> None:
    """app.js must define a renderViewsSettingsTab function."""
    assert "function renderViewsSettingsTab" in _JS, (
        "app.js must contain a renderViewsSettingsTab function"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_settings_has_views_tab_button muxplex/tests/test_frontend_js.py::test_render_views_settings_tab_function_exists -v

Expected: FAIL — no views tab or function

Step 3: Apply the HTML changes to muxplex/frontend/index.html

Change 1 — Add the "Views" tab button in the settings tabs nav (after the "Sessions" button, line ~98):

        <button class="settings-tab" data-tab="sessions">Sessions</button>
        <button class="settings-tab" data-tab="views">Views</button>

Change 2 — Add the views panel (after the sessions panel closing </div>, before the new-session panel, after line ~194):

        <div class="settings-panel hidden" data-tab="views">
          <div id="views-settings-list" class="views-settings-list"></div>
          <p class="settings-helper" id="views-settings-empty" style="display:none">No user-created views yet. Use the dropdown in the header to create one.</p>
        </div>

Step 4: Apply the JS changes to muxplex/frontend/app.js

Insert after showNewViewInput():

/**
 * Render the Views tab content in the settings dialog.
 * Shows a list of user views with: inline rename, up/down reorder buttons,
 * and delete with inline "Sure? [Yes] [No]" confirmation.
 */
function renderViewsSettingsTab() {
  var container = $('views-settings-list');
  var emptyMsg = $('views-settings-empty');
  if (!container) return;

  var views = (_serverSettings && _serverSettings.views) || [];

  if (views.length === 0) {
    container.innerHTML = '';
    if (emptyMsg) emptyMsg.style.display = '';
    return;
  }
  if (emptyMsg) emptyMsg.style.display = 'none';

  var html = '';
  for (var i = 0; i < views.length; i++) {
    var v = views[i];
    html += '<div class="views-settings-row" data-view-index="' + i + '">';
    html += '<span class="views-settings-name" data-view-name="' + escapeHtml(v.name) + '" tabindex="0" role="button" title="Click to rename">' + escapeHtml(v.name) + '</span>';
    html += '<span class="views-settings-count">' + (v.sessions ? v.sessions.length : 0) + ' sessions</span>';
    html += '<div class="views-settings-actions">';
    html += '<button class="views-settings-btn" data-action="move-up" title="Move up"' + (i === 0 ? ' disabled' : '') + '>&uarr;</button>';
    html += '<button class="views-settings-btn" data-action="move-down" title="Move down"' + (i === views.length - 1 ? ' disabled' : '') + '>&darr;</button>';
    html += '<button class="views-settings-btn views-settings-btn--danger" data-action="delete" title="Delete view">&times;</button>';
    html += '</div>';
    html += '</div>';
  }

  container.innerHTML = html;

  // Delegated handlers
  container.onclick = function(e) {
    var target = e.target;

    // Move up
    if (target.dataset.action === 'move-up') {
      var row = target.closest('[data-view-index]');
      var idx = parseInt(row.dataset.viewIndex, 10);
      if (idx > 0) {
        var updatedViews = JSON.parse(JSON.stringify(views));
        var tmp = updatedViews[idx - 1];
        updatedViews[idx - 1] = updatedViews[idx];
        updatedViews[idx] = tmp;
        _saveViewsAndRerender(updatedViews);
      }
      return;
    }

    // Move down
    if (target.dataset.action === 'move-down') {
      var row = target.closest('[data-view-index]');
      var idx = parseInt(row.dataset.viewIndex, 10);
      if (idx < views.length - 1) {
        var updatedViews = JSON.parse(JSON.stringify(views));
        var tmp = updatedViews[idx + 1];
        updatedViews[idx + 1] = updatedViews[idx];
        updatedViews[idx] = tmp;
        _saveViewsAndRerender(updatedViews);
      }
      return;
    }

    // Delete — show inline confirmation
    if (target.dataset.action === 'delete') {
      var actionsDiv = target.closest('.views-settings-actions');
      if (!actionsDiv) return;
      actionsDiv.innerHTML =
        '<span class="views-settings-confirm">Sure?</span> ' +
        '<button class="views-settings-btn views-settings-btn--danger" data-action="confirm-delete">Yes</button> ' +
        '<button class="views-settings-btn" data-action="cancel-delete">No</button>';
      return;
    }

    // Confirm delete
    if (target.dataset.action === 'confirm-delete') {
      var row = target.closest('[data-view-index]');
      var idx = parseInt(row.dataset.viewIndex, 10);
      var deletedName = views[idx].name;
      var updatedViews = JSON.parse(JSON.stringify(views));
      updatedViews.splice(idx, 1);
      // If the deleted view is the active view, fall back to "all"
      if (_activeView === deletedName) {
        switchView('all');
      }
      _saveViewsAndRerender(updatedViews);
      return;
    }

    // Cancel delete
    if (target.dataset.action === 'cancel-delete') {
      renderViewsSettingsTab();
      return;
    }

    // Click on name — inline rename
    var nameEl = target.closest('.views-settings-name');
    if (nameEl) {
      var currentName = nameEl.dataset.viewName;
      var input = document.createElement('input');
      input.type = 'text';
      input.className = 'settings-input views-settings-rename-input';
      input.value = currentName;
      input.maxLength = 30;
      nameEl.replaceWith(input);
      input.focus();
      input.select();

      function commitRename() {
        var newName = input.value.trim();
        if (!newName || newName === currentName) {
          renderViewsSettingsTab();
          return;
        }
        var lower = newName.toLowerCase();
        if (lower === 'all' || lower === 'hidden') {
          showToast('"' + newName + '" is a reserved name');
          renderViewsSettingsTab();
          return;
        }
        // Check duplicate
        for (var j = 0; j < views.length; j++) {
          if (views[j].name === newName) {
            showToast('A view named "' + newName + '" already exists');
            renderViewsSettingsTab();
            return;
          }
        }
        var updatedViews = JSON.parse(JSON.stringify(views));
        var row = input.closest('[data-view-index]');
        var idx = parseInt(row.dataset.viewIndex, 10);
        updatedViews[idx].name = newName;
        // If the renamed view is the active view, update active view name
        if (_activeView === currentName) {
          _activeView = newName;
          api('PATCH', '/api/state', { active_view: newName }).catch(function() {});
        }
        _saveViewsAndRerender(updatedViews);
      }

      input.addEventListener('keydown', function(e) {
        if (e.key === 'Enter') { commitRename(); }
        else if (e.key === 'Escape') { renderViewsSettingsTab(); }
      });
      input.addEventListener('blur', function() {
        setTimeout(commitRename, 100);
      });
    }
  };
}

/**
 * Save updated views array via PATCH /api/settings and re-render the Views tab.
 * @param {Array} updatedViews
 */
function _saveViewsAndRerender(updatedViews) {
  api('PATCH', '/api/settings', { views: updatedViews })
    .then(function() {
      if (_serverSettings) _serverSettings.views = updatedViews;
      renderViewsSettingsTab();
      renderViewDropdown();
    })
    .catch(function(err) {
      showToast('Failed to save views');
      console.warn('[_saveViewsAndRerender] PATCH failed:', err);
      renderViewsSettingsTab();
    });
}

Change 2 — Call renderViewsSettingsTab() inside openSettings() (line ~1670, after the loadServerSettings().then(function(ss) { block, at the end of the .then() callback before the closing });):

    // Render Views tab content
    renderViewsSettingsTab();

Change 3 — Add renderViewsSettingsTab and _saveViewsAndRerender to window.MuxplexApp:

    renderViewsSettingsTab,

Step 5: Add CSS for the views settings rows to muxplex/frontend/style.css

Append after the view dropdown styles:

/* ── Views Settings Tab ────────────────────────────────────────────── */

.views-settings-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.views-settings-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: 6px;
  background: var(--bg);
}

.views-settings-row:hover {
  background: var(--bg-surface);
}

.views-settings-name {
  flex: 1;
  min-width: 0;
  font-size: 13px;
  color: var(--text);
  cursor: pointer;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.views-settings-name:hover {
  text-decoration: underline;
  text-decoration-style: dotted;
}

.views-settings-count {
  font-size: 11px;
  color: var(--text-dim);
  white-space: nowrap;
}

.views-settings-actions {
  display: flex;
  gap: 4px;
  flex-shrink: 0;
}

.views-settings-btn {
  width: 24px;
  height: 24px;
  padding: 0;
  background: transparent;
  border: 1px solid var(--border-subtle);
  border-radius: 4px;
  color: var(--text-muted);
  font-size: 12px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.views-settings-btn:hover:not(:disabled) {
  border-color: var(--border);
  color: var(--text);
}

.views-settings-btn:disabled {
  opacity: 0.3;
  cursor: default;
}

.views-settings-btn--danger:hover:not(:disabled) {
  border-color: var(--err);
  color: var(--err);
}

.views-settings-confirm {
  font-size: 12px;
  color: var(--text-muted);
}

.views-settings-rename-input {
  flex: 1;
  min-width: 0;
}

Step 6: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "views_tab or views_panel" muxplex/tests/test_frontend_js.py::test_render_views_settings_tab_function_exists -v

Expected: All 3 new tests PASS

Step 7: Commit

cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js muxplex/frontend/style.css muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_js.py && git commit -m "feat: add Manage Views settings tab with rename, reorder, delete"

Task 10: Sidebar View Switcher in Fullscreen Mode

Files:

  • Modify: muxplex/frontend/index.html (sidebar header, line ~44)
  • Modify: muxplex/frontend/app.js
  • Modify: muxplex/frontend/style.css
  • Modify: muxplex/tests/test_frontend_html.py

Add a dropdown to the fullscreen sidebar header, same render function as the header dropdown but a separate DOM instance.

Step 1: Write the failing test

Add to muxplex/tests/test_frontend_html.py:

def test_sidebar_view_dropdown_exists() -> None:
    """Sidebar must contain a view-dropdown trigger for fullscreen view switching."""
    assert 'id="sidebar-view-dropdown-trigger"' in _HTML, (
        "index.html must contain a #sidebar-view-dropdown-trigger element"
    )


def test_sidebar_view_dropdown_menu_exists() -> None:
    """Sidebar must contain a view-dropdown-menu container."""
    assert 'id="sidebar-view-dropdown-menu"' in _HTML, (
        "index.html must contain a #sidebar-view-dropdown-menu element"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_sidebar_view_dropdown_exists -v

Expected: FAIL

Step 3: Apply the HTML change to muxplex/frontend/index.html

Replace the sidebar-header div (lines ~4447):

        <div class="sidebar-header">
          <div class="sidebar-view-dropdown" id="sidebar-view-dropdown">
            <button id="sidebar-view-dropdown-trigger" class="sidebar-view-trigger" aria-haspopup="true" aria-expanded="false" aria-controls="sidebar-view-dropdown-menu">
              <span id="sidebar-view-label">All Sessions</span>
              <span class="view-dropdown__caret" aria-hidden="true">&#9662;</span>
            </button>
            <div id="sidebar-view-dropdown-menu" class="view-dropdown__menu hidden" role="menu" aria-label="Switch view"></div>
          </div>
          <button id="sidebar-collapse-btn" class="sidebar-collapse-btn" aria-label="Collapse session list">&#8249;</button>
        </div>

Step 4: Apply the JS changes to muxplex/frontend/app.js

Add a sidebar-specific render and toggle function. Insert after closeViewDropdown():

/**
 * Render the sidebar view dropdown (fullscreen mode).
 * Uses the same data as the header dropdown but targets #sidebar-view-dropdown-menu.
 */
function renderSidebarViewDropdown() {
  var menu = $('sidebar-view-dropdown-menu');
  if (!menu) return;

  // Reuse the same rendering logic by temporarily swapping target IDs
  var headerMenu = $('view-dropdown-menu');
  var headerLabel = $('view-dropdown-label');

  // Render into the sidebar menu directly (same HTML structure)
  var views = (_serverSettings && _serverSettings.views) || [];
  var hidden = (_serverSettings && _serverSettings.hidden_sessions) || [];
  var hiddenCount = hidden.length;

  var html = '';
  var allActive = _activeView === 'all' ? ' view-dropdown__item--active' : '';
  html += '<button class="view-dropdown__item' + allActive + '" role="menuitem" data-view="all">All Sessions</button>';

  if (views.length > 0) html += '<div class="view-dropdown__separator" role="separator"></div>';
  for (var i = 0; i < views.length; i++) {
    var vActive = _activeView === views[i].name ? ' view-dropdown__item--active' : '';
    html += '<button class="view-dropdown__item' + vActive + '" role="menuitem" data-view="' + escapeHtml(views[i].name) + '">' + escapeHtml(views[i].name) + '</button>';
  }

  html += '<div class="view-dropdown__separator" role="separator"></div>';
  var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : '';
  html += '<button class="view-dropdown__item' + hiddenActive + '" role="menuitem" data-view="hidden">Hidden';
  if (hiddenCount > 0) html += ' <span class="view-dropdown__count">(' + hiddenCount + ')</span>';
  html += '</button>';

  menu.innerHTML = html;

  // Update sidebar label
  var label = $('sidebar-view-label');
  if (label) {
    if (_activeView === 'all') label.textContent = 'All Sessions';
    else if (_activeView === 'hidden') label.textContent = 'Hidden';
    else label.textContent = _activeView;
  }
}

/**
 * Toggle the sidebar view dropdown open/closed.
 */
function toggleSidebarViewDropdown() {
  var menu = $('sidebar-view-dropdown-menu');
  var trigger = $('sidebar-view-dropdown-trigger');
  if (!menu) return;
  var isOpen = !menu.classList.contains('hidden');
  if (isOpen) {
    menu.classList.add('hidden');
    if (trigger) trigger.setAttribute('aria-expanded', 'false');
  } else {
    renderSidebarViewDropdown();
    menu.classList.remove('hidden');
    if (trigger) trigger.setAttribute('aria-expanded', 'true');
  }
}

Change 2 — Bind the sidebar dropdown events in bindStaticEventListeners(). Add after the header dropdown bindings:

  // Sidebar view dropdown (fullscreen)
  on($('sidebar-view-dropdown-trigger'), 'click', toggleSidebarViewDropdown);
  var sidebarViewMenu = $('sidebar-view-dropdown-menu');
  if (sidebarViewMenu) {
    sidebarViewMenu.addEventListener('click', function(e) {
      var item = e.target.closest('[data-view]');
      if (item) {
        switchView(item.dataset.view);
        // Close sidebar dropdown
        sidebarViewMenu.classList.add('hidden');
        var trigger = $('sidebar-view-dropdown-trigger');
        if (trigger) trigger.setAttribute('aria-expanded', 'false');
      }
    });
  }

Change 3 — Update switchView() to also update the sidebar label. Add after the existing label update:

  // Update sidebar label too
  var sidebarLabel = $('sidebar-view-label');
  if (sidebarLabel) {
    if (viewName === 'all') sidebarLabel.textContent = 'All Sessions';
    else if (viewName === 'hidden') sidebarLabel.textContent = 'Hidden';
    else sidebarLabel.textContent = viewName;
  }

Step 5: Add sidebar dropdown CSS to muxplex/frontend/style.css

Add after the view dropdown styles:

.sidebar-view-dropdown {
  position: relative;
  flex: 1;
  min-width: 0;
}

.sidebar-view-trigger {
  display: flex;
  align-items: center;
  gap: 4px;
  background: transparent;
  border: none;
  padding: 2px 4px;
  color: var(--text);
  font-size: 13px;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

.sidebar-view-trigger:hover {
  color: var(--accent);
}

.sidebar-view-dropdown .view-dropdown__menu {
  left: 0;
  top: calc(100% + 2px);
  min-width: 180px;
}

Step 6: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "sidebar_view" -v

Expected: Both new tests PASS

Step 7: Commit

cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js muxplex/frontend/style.css muxplex/tests/test_frontend_html.py && git commit -m "feat: add sidebar view switcher dropdown in fullscreen mode"

Task 11: Remove filtered gridViewMode Rendering Code

Files:

  • Modify: muxplex/frontend/app.js (lines ~761764, ~780787, ~820827)
  • Modify: muxplex/frontend/index.html (line ~220, the "Filtered" option)
  • Modify: muxplex/tests/test_frontend_js.py

Phase 1 removed filtered from the settings value but may not have removed the rendering code. This task removes the filter pill bar rendering, the _activeFilterDevice filter logic in renderGrid(), and the "Filtered" option from the HTML select.

Step 1: Write the failing tests

Add to muxplex/tests/test_frontend_js.py:

def test_render_grid_no_filtered_mode_check() -> None:
    """renderGrid must not check for _gridViewMode === 'filtered'."""
    fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
    assert "'filtered'" not in fn_body and '"filtered"' not in fn_body, (
        "renderGrid must not contain any 'filtered' mode checks"
    )


def test_no_active_filter_device_in_render_grid() -> None:
    """renderGrid must not reference _activeFilterDevice."""
    fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0]
    assert "_activeFilterDevice" not in fn_body, (
        "renderGrid must not reference _activeFilterDevice"
    )

Add to muxplex/tests/test_frontend_html.py:

def test_no_filtered_option_in_view_mode_select() -> None:
    """The view mode select must not have a 'filtered' option."""
    # Find the setting-view-mode select
    assert '>Filtered<' not in _HTML, (
        "The view mode select must not have a 'Filtered' option"
    )

Step 2: Run tests to verify they fail

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_no_filtered_mode_check -v

Expected: FAIL — 'filtered' is still in renderGrid

Step 3: Apply the changes

Change 1 — In muxplex/frontend/app.js, remove filtered mode code from renderGrid():

Remove lines ~761764 (the filtered device filter block):

  // DELETE THIS BLOCK:
  // In filtered mode, apply device filter
  if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') {
    visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; });
  }

Remove lines ~780787 (the filter bar rendering in the empty-state branch):

  // DELETE THIS BLOCK:
    // Show filter bar even when filtered to empty (so user can switch back)
    if (filterBar) {
      if (_gridViewMode === 'filtered') {
        renderFilterBar(filterBar, sessions);
      } else {
        filterBar.innerHTML = '';
      }
    }

Replace with:

    if (filterBar) filterBar.innerHTML = '';

Remove lines ~820827 (the filter bar rendering in the normal branch):

  // DELETE THIS BLOCK:
  // Render filter bar
  if (filterBar) {
    if (_gridViewMode === 'filtered') {
      renderFilterBar(filterBar, sessions);
    } else {
      filterBar.innerHTML = '';
    }
  }

Replace with:

  if (filterBar) filterBar.innerHTML = '';

Change 2 — In loadGridViewMode() (line ~1547), add the fallback guard if not already present from Phase 1:

function loadGridViewMode() {
  var ds = getDisplaySettings();
  var mode = ds.gridViewMode || 'flat';
  if (mode === 'filtered') mode = 'flat';
  return mode;
}

Change 3 — In _setGridViewMode() test helper, add the guard:

function _setGridViewMode(mode) {
  if (mode === 'filtered') mode = 'flat';
  _gridViewMode = mode;
}

Change 4 — In muxplex/frontend/index.html, remove the "Filtered" option from the view mode select (line ~220):

<!-- DELETE THIS LINE: -->
                <option value="filtered">Filtered</option>

Step 4: Run tests to verify they pass

cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_no_filtered_mode_check muxplex/tests/test_frontend_js.py::test_no_active_filter_device_in_render_grid muxplex/tests/test_frontend_html.py::test_no_filtered_option_in_view_mode_select -v

Expected: All 3 PASS

Step 5: Commit

cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/index.html muxplex/tests/test_frontend_js.py muxplex/tests/test_frontend_html.py && git commit -m "feat: remove filtered gridViewMode rendering code and filter pill bar"

Task 12: Remove Hidden Sessions Section from Settings Panel

Files:

  • Modify: muxplex/frontend/index.html (lines ~190193)
  • Modify: muxplex/frontend/app.js (the hidden sessions checkbox population in openSettings())
  • Modify: muxplex/tests/test_frontend_html.py

The "Hidden Sessions" checkbox list in the Sessions settings tab is being replaced by the Hidden view + tile flyout (Phase 3). Remove it now since the Hidden view already exists via the dropdown.

Step 1: Write the failing test

Add to muxplex/tests/test_frontend_html.py:

def test_no_hidden_sessions_checkbox_list_in_settings() -> None:
    """The settings panel must not contain the hidden-sessions checkbox section."""
    assert 'id="setting-hidden-sessions"' not in _HTML, (
        "The settings panel must not contain #setting-hidden-sessions — it has been replaced by the Hidden view"
    )

Step 2: Run test to verify it fails

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_no_hidden_sessions_checkbox_list_in_settings -v

Expected: FAIL — setting-hidden-sessions still in HTML

Step 3: Apply the HTML change to muxplex/frontend/index.html

Remove lines ~190193 (the Hidden Sessions field in the sessions panel):

<!-- DELETE THIS BLOCK: -->
          <div class="settings-field settings-field--column">
            <label class="settings-label">Hidden Sessions</label>
            <div id="setting-hidden-sessions" class="settings-checkbox-list"></div>
          </div>

Step 4: Apply the JS change to muxplex/frontend/app.js

In openSettings(), remove the hidden sessions checkbox population block (lines ~16921710):

    // DELETE THIS BLOCK:
    // Hidden sessions checkboxes
    const hiddenSessionsEl = $('setting-hidden-sessions');
    if (hiddenSessionsEl) {
      hiddenSessionsEl.innerHTML = '';
      const hiddenList = (ss && ss.hidden_sessions) || [];
      (_currentSessions || []).forEach(function(s) {
        const name = s.name || '';
        const item = document.createElement('label');
        item.className = 'settings-checkbox-item';
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.className = 'settings-checkbox';
        cb.value = name;
        cb.checked = hiddenList.includes(name);
        item.appendChild(cb);
        item.appendChild(document.createTextNode(' ' + name));
        hiddenSessionsEl.appendChild(item);
      });
    }

Also remove the hidden sessions delegated event handler in bindStaticEventListeners() (lines ~23002312):

  // DELETE THIS BLOCK:
  // Hidden sessions — delegated handler on container (checkboxes are dynamic)
  var hiddenSessionsContainer = $('setting-hidden-sessions');
  if (hiddenSessionsContainer) {
    hiddenSessionsContainer.addEventListener('change', function(e) {
      var cb = e.target.closest('input[type="checkbox"]');
      if (!cb) return;
      var hidden = [];
      hiddenSessionsContainer.querySelectorAll('input[type="checkbox"]').forEach(function(c) {
        if (c.checked) hidden.push(c.value);
      });
      patchServerSetting('hidden_sessions', hidden);
    });
  }

Step 5: Run test to verify it passes

cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_no_hidden_sessions_checkbox_list_in_settings -v

Expected: PASS

Step 6: Commit

cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/app.js muxplex/tests/test_frontend_html.py && git commit -m "feat: remove hidden sessions checkbox section from settings (replaced by Hidden view)"

Task 13: Final Integration — Run Full Test Suite

Files: None (verification only)

Step 1: Run the complete test suite

cd muxplex && python -m pytest muxplex/tests/ -v --timeout=120

Expected: All tests PASS

Step 2: Run quality checks

cd muxplex && python -m ruff check muxplex/
cd muxplex && python -m ruff format --check muxplex/

Expected: No errors. Fix any formatting or lint issues.

Step 3: Verify the Phase 2 feature chain end-to-end

Check that the full chain works by listing what Phase 2 established:

  1. PATCH /api/state accepts active_view field ✓
  2. getVisibleSessions() filters by active view (all/hidden/user view) ✓
  3. New sessions auto-add to active user view on creation ✓
  4. Header dropdown HTML with ARIA attributes ✓
  5. Header dropdown CSS ✓
  6. renderViewDropdown() / toggleViewDropdown() / switchView()
  7. Keyboard shortcuts: backtick (grid only), 19 number keys, arrow navigation ✓
  8. Inline "New View" creation from dropdown ✓
  9. "Manage Views" settings tab (rename, reorder, delete) ✓
  10. Sidebar view switcher in fullscreen mode ✓
  11. filtered gridViewMode rendering code removed ✓
  12. Hidden sessions checkbox section removed from settings ✓

Step 4: Commit any remaining fixes

cd muxplex && git add -A && git status

If there are uncommitted changes, commit them:

cd muxplex && git commit -m "chore: phase 2 integration fixes"

Deferred to Phase 3

The following are explicitly NOT in Phase 2:

  • Tile flyout menu ( button, context menu, Add to View submenu, Remove from View, Hide/Unhide)
  • Add Sessions panel (overlay with checkboxes for adding sessions to a view)
  • Kill session inline confirmation (replacing the current confirm() dialog with inline Yes/No)
  • Hide/Unhide actions via flyout (currently only accessible via the Hidden view dropdown)
  • Mobile bottom sheet variants for the tile flyout and Add Sessions panel
  • Session key migration (rewriting old positional remoteId:name keys — the integer fallback from Phase 1 provides backward compatibility)
  • Config path file migration (moving files from ~/.local/share/tmux-web/ to ~/.local/share/muxplex/)