# 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 ~425–502) - 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: ```python # --------------------------------------------------------------------------- # 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** ```bash 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): ```python 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): ```python if "active_view" in changed: state["active_view"] = patch.active_view ``` **Step 4: Run tests to verify they pass** ```bash 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** ```bash 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 ~537–547) - 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`: ```python # --------------------------------------------------------------------------- # 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** ```bash 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): ```javascript let _activeView = 'all'; ``` Change 2 — Rewrite `getVisibleSessions()` (replace lines 537–547): ```javascript /** * 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): ```javascript /** 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`): ```javascript _getActiveView, _setActiveView, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "active_view" -v ``` Expected: All new tests PASS **Step 5: Commit** ```bash 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`: ```python 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** ```bash 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): ```javascript // 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** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_create_new_session_references_active_view -v ``` Expected: PASS **Step 5: Commit** ```bash 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 20–28, the `
` 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`: ```python 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** ```bash 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 20–28) with: ```html

muxplex

``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "view_dropdown" -v ``` Expected: All 4 new tests PASS **Step 5: Commit** ```bash 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`: ```python 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** ```bash 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: ```css /* ── 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** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py -k "view_dropdown" -v ``` Expected: All 3 new tests PASS **Step 5: Commit** ```bash 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`: ```python 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** ```bash 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()`: ```javascript // ── 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 += ''; // Separator before user views (if any) if (views.length > 0) { html += ''; } // User views — in array order, shortcuts 2–8 for (var i = 0; i < views.length; i++) { var vActive = _activeView === views[i].name ? ' view-dropdown__item--active' : ''; html += ''; } // Separator before Hidden html += ''; // "Hidden (N)" — always last system view, shortcut: 9 var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; html += ''; // Separator before actions html += ''; // "+ New View" action html += ''; // "Manage Views..." action html += ''; 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,`): ```javascript // View dropdown renderViewDropdown, toggleViewDropdown, closeViewDropdown, switchView, ``` Change 3 — Bind event listeners in `bindStaticEventListeners()`. Add after the settings dialog bindings section (after line ~2226): ```javascript // 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: ```javascript // Restore active_view from server state if (state.active_view) { _activeView = state.active_view; renderViewDropdown(); } ``` **Step 4: Run tests to verify they pass** ```bash 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** ```bash 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 1–9 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`: ```python 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** ```bash 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 ~1812–1830): ```javascript /** * 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** ```bash 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** ```bash 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`: ```python 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** ```bash 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()`: ```javascript /** * 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,`): ```javascript showNewViewInput, ``` **Step 4: Run tests to verify they pass** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "new_view_input" -v ``` Expected: Both PASS **Step 5: Commit** ```bash 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`: ```python 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`: ```python 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** ```bash 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): ```html ``` Change 2 — Add the views panel (after the sessions panel closing ``, before the `new-session` panel, after line ~194): ```html ``` **Step 4: Apply the JS changes to `muxplex/frontend/app.js`** Insert after `showNewViewInput()`: ```javascript /** * 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 += '
'; html += '' + escapeHtml(v.name) + ''; html += '' + (v.sessions ? v.sessions.length : 0) + ' sessions'; html += '
'; html += ''; html += ''; html += ''; html += '
'; html += '
'; } 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 = 'Sure? ' + ' ' + ''; 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 `});`): ```javascript // Render Views tab content renderViewsSettingsTab(); ``` Change 3 — Add `renderViewsSettingsTab` and `_saveViewsAndRerender` to `window.MuxplexApp`: ```javascript renderViewsSettingsTab, ``` **Step 5: Add CSS for the views settings rows to `muxplex/frontend/style.css`** Append after the view dropdown styles: ```css /* ── 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** ```bash 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** ```bash 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`: ```python 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** ```bash 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 ~44–47): ```html ``` **Step 4: Apply the JS changes to `muxplex/frontend/app.js`** Add a sidebar-specific render and toggle function. Insert after `closeViewDropdown()`: ```javascript /** * 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 += ''; if (views.length > 0) html += ''; for (var i = 0; i < views.length; i++) { var vActive = _activeView === views[i].name ? ' view-dropdown__item--active' : ''; html += ''; } html += ''; var hiddenActive = _activeView === 'hidden' ? ' view-dropdown__item--active' : ''; html += ''; 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: ```javascript // 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: ```javascript // 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: ```css .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** ```bash cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "sidebar_view" -v ``` Expected: Both new tests PASS **Step 7: Commit** ```bash 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 ~761–764, ~780–787, ~820–827) - 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`: ```python 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`: ```python 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** ```bash 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 ~761–764 (the filtered device filter block): ```javascript // 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 ~780–787 (the filter bar rendering in the empty-state branch): ```javascript // 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: ```javascript if (filterBar) filterBar.innerHTML = ''; ``` Remove lines ~820–827 (the filter bar rendering in the normal branch): ```javascript // DELETE THIS BLOCK: // Render filter bar if (filterBar) { if (_gridViewMode === 'filtered') { renderFilterBar(filterBar, sessions); } else { filterBar.innerHTML = ''; } } ``` Replace with: ```javascript if (filterBar) filterBar.innerHTML = ''; ``` Change 2 — In `loadGridViewMode()` (line ~1547), add the fallback guard if not already present from Phase 1: ```javascript 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: ```javascript 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): ```html ``` **Step 4: Run tests to verify they pass** ```bash 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** ```bash 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 ~190–193) - 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`: ```python 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** ```bash 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 ~190–193 (the Hidden Sessions field in the sessions panel): ```html
``` **Step 4: Apply the JS change to `muxplex/frontend/app.js`** In `openSettings()`, remove the hidden sessions checkbox population block (lines ~1692–1710): ```javascript // 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 ~2300–2312): ```javascript // 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** ```bash 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** ```bash 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** ```bash cd muxplex && python -m pytest muxplex/tests/ -v --timeout=120 ``` Expected: All tests PASS **Step 2: Run quality checks** ```bash 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), 1–9 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** ```bash cd muxplex && git add -A && git status ``` If there are uncommitted changes, commit them: ```bash 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/`)