2037 lines
66 KiB
Markdown
2037 lines
66 KiB
Markdown
# 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 `<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`:
|
||
|
||
```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
|
||
<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">▾</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">▦</button>
|
||
<button id="settings-btn" class="header-btn" aria-label="Settings">⚙</button>
|
||
<span id="connection-status"></span>
|
||
</div>
|
||
</header>
|
||
```
|
||
|
||
**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 += '<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 2–8
|
||
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,`):
|
||
```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
|
||
<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):
|
||
```html
|
||
<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()`:
|
||
|
||
```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 += '<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' : '') + '>↑</button>';
|
||
html += '<button class="views-settings-btn" data-action="move-down" title="Move down"' + (i === views.length - 1 ? ' disabled' : '') + '>↓</button>';
|
||
html += '<button class="views-settings-btn views-settings-btn--danger" data-action="delete" title="Delete view">×</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 `});`):
|
||
|
||
```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
|
||
<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">▾</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">‹</button>
|
||
</div>
|
||
```
|
||
|
||
**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 += '<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:
|
||
|
||
```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
|
||
<!-- DELETE THIS LINE: -->
|
||
<option value="filtered">Filtered</option>
|
||
```
|
||
|
||
**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
|
||
<!-- 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 ~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/`) |