diff --git a/README.md b/README.md index 77d1d53..1490f49 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ All settings are stored in `~/.config/muxplex/settings.json`. | `default_session` | `null` | Session to auto-open on load | | `sort_order` | `manual` | Session ordering: `manual`, `alphabetical`, `recent` | | `hidden_sessions` | `[]` | Sessions hidden from the dashboard | +| `views` | `[]` | Named session views for grouping and filtering sessions | | `window_size_largest` | `false` | Auto-set tmux `window-size largest` on connect | | `auto_open_created` | `true` | Auto-open newly created sessions | | `new_session_template` | `tmux new-session -d -s {name}` | Command template for creating sessions | @@ -254,6 +255,7 @@ All settings are stored in `~/.config/muxplex/settings.json`. | `activityIndicator` | `"both"` | Activity style: `none`, `glow`, `dot`, `both` | | `gridViewMode` | `"flat"` | Multi-device grid layout: `flat`, `grouped`, `filtered` | | `sidebarOpen` | `null` | Sidebar state: `true`, `false`, or `null` (auto-detect from screen width) | +| `settings_updated_at` | `0.0` | Unix timestamp of last settings write (used for federation sync) | **Priority:** CLI flags > `settings.json` > defaults. diff --git a/docs/plans/2026-04-15-views-phase1-implementation.md b/docs/plans/2026-04-15-views-phase1-implementation.md new file mode 100644 index 0000000..5f04dff --- /dev/null +++ b/docs/plans/2026-04-15-views-phase1-implementation.md @@ -0,0 +1,1613 @@ +# Views Feature — Phase 1: Stable Device Identity + Data Model + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Establish stable device identity and data model changes so the backend and data layer are ready for the Views UI work in Phase 2. + +**Architecture:** Each muxplex instance gets a persistent UUID stored in `~/.config/muxplex/identity.json` (outside federation sync boundary). All session keys change from positional `remoteId:name` to `device_id:name` uniformly for both local and remote sessions. Settings gain a `views` array (synced) and state gains an `active_view` field (per-device, not synced). Federation proxy endpoints switch from integer index to device_id string lookup. + +**Tech Stack:** Python 3.12+ / FastAPI / pytest + pytest-asyncio / vanilla JS + +**Design reference:** `docs/plans/2026-04-15-views-design.md` + +--- + +## Task 1: Create `muxplex/identity.py` — Device Identity Module + +**Files:** +- Create: `muxplex/identity.py` +- Create: `muxplex/tests/test_identity.py` + +**Step 1: Write the failing tests** + +Create `muxplex/tests/test_identity.py`: + +```python +""" +Tests for muxplex/identity.py — device identity management. +""" + +import json +import uuid +from pathlib import Path + +import pytest + +import muxplex.identity as identity_mod +from muxplex.identity import ( + IDENTITY_PATH, + load_device_id, + reset_device_id, +) + + +# --------------------------------------------------------------------------- +# Autouse fixture: redirect IDENTITY_PATH to tmp_path +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def redirect_identity_path(tmp_path, monkeypatch): + """Redirect IDENTITY_PATH to a temporary file for all tests.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + return fake_path + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_load_creates_file_when_absent(tmp_path, monkeypatch): + """load_device_id() creates identity.json with a valid UUID when no file exists.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + + device_id = load_device_id() + + assert fake_path.exists() + # Must be a valid UUID + uuid.UUID(device_id) + + +def test_load_returns_same_id_on_repeated_calls(tmp_path, monkeypatch): + """load_device_id() returns the same device_id on repeated calls.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + + first = load_device_id() + second = load_device_id() + assert first == second + + +def test_load_reads_existing_file(tmp_path, monkeypatch): + """load_device_id() reads an existing identity.json without overwriting.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + expected_id = str(uuid.uuid4()) + fake_path.write_text(json.dumps({"device_id": expected_id})) + + assert load_device_id() == expected_id + + +def test_load_creates_parent_dirs(tmp_path, monkeypatch): + """load_device_id() creates parent directories if needed.""" + nested_path = tmp_path / "a" / "b" / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", nested_path) + + load_device_id() + assert nested_path.exists() + + +def test_load_regenerates_on_corrupt_json(tmp_path, monkeypatch): + """load_device_id() generates a new id when identity.json is corrupt.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + fake_path.write_text("not valid json{{{") + + device_id = load_device_id() + uuid.UUID(device_id) # must be valid + + +def test_load_regenerates_on_missing_key(tmp_path, monkeypatch): + """load_device_id() generates a new id when device_id key is missing from JSON.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + fake_path.write_text(json.dumps({"other_key": "value"})) + + device_id = load_device_id() + uuid.UUID(device_id) # must be valid + + +def test_reset_generates_new_id(tmp_path, monkeypatch): + """reset_device_id() writes a new UUID different from the previous one.""" + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + + original = load_device_id() + new_id = reset_device_id() + + assert new_id != original + uuid.UUID(new_id) # must be valid + # File on disk must match + data = json.loads(fake_path.read_text()) + assert data["device_id"] == new_id +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_identity.py -v +``` +Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.identity'` + +**Step 3: Write the implementation** + +Create `muxplex/identity.py`: + +```python +""" +Device identity management for muxplex. + +Each muxplex instance gets a persistent device_id (UUID v4) stored in +~/.config/muxplex/identity.json. This file is explicitly outside the +federation settings sync boundary — it is never synced, never overwritten +by settings propagation. + +The device_id is generated once on first startup and never regenerated +automatically. The --reset-device-id CLI command can generate a new one +for the "I copied my dotfiles" scenario. +""" + +import json +import uuid +from pathlib import Path + +IDENTITY_PATH = Path.home() / ".config" / "muxplex" / "identity.json" + + +def load_device_id() -> str: + """Load the device_id from identity.json, generating one if absent. + + Creates the file and parent directories on first call. + Regenerates if the file is corrupt or missing the device_id key. + """ + try: + data = json.loads(IDENTITY_PATH.read_text()) + device_id = data.get("device_id", "") + if device_id: + return device_id + except (FileNotFoundError, json.JSONDecodeError, OSError): + pass + + # Generate and persist a new device_id + return reset_device_id() + + +def reset_device_id() -> str: + """Generate a new device_id, write it to identity.json, and return it. + + Overwrites any existing device_id. Creates parent directories if needed. + """ + device_id = str(uuid.uuid4()) + IDENTITY_PATH.parent.mkdir(parents=True, exist_ok=True) + IDENTITY_PATH.write_text(json.dumps({"device_id": device_id}, indent=2) + "\n") + return device_id +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_identity.py -v +``` +Expected: All 8 tests PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/identity.py muxplex/tests/test_identity.py && git commit -m "feat: add identity.py for stable device identity (UUID in identity.json)" +``` + +--- + +## Task 2: Update `state.py` — New `muxplex` State Path + `active_view` Field + +**Files:** +- Modify: `muxplex/state.py` +- Modify: `muxplex/tests/test_state.py` + +**Step 1: Write the failing tests** + +Add to the end of `muxplex/tests/test_state.py`: + +```python +# --------------------------------------------------------------------------- +# active_view field in empty_state +# --------------------------------------------------------------------------- + + +def test_empty_state_has_active_view_key(): + state = empty_state() + assert "active_view" in state + + +def test_empty_state_active_view_defaults_to_all(): + state = empty_state() + assert state["active_view"] == "all" + + +# --------------------------------------------------------------------------- +# State path migration: tmux-web -> muxplex +# --------------------------------------------------------------------------- + + +def test_state_dir_uses_muxplex_name(): + """STATE_DIR default should use 'muxplex', not 'tmux-web'.""" + import muxplex.state as state_mod + + # The _default_state_dir (used when env var is not set) must contain 'muxplex' + assert "muxplex" in str(state_mod._default_state_dir) + assert "tmux-web" not in str(state_mod._default_state_dir) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_state.py::test_empty_state_has_active_view_key muxplex/tests/test_state.py::test_empty_state_active_view_defaults_to_all muxplex/tests/test_state.py::test_state_dir_uses_muxplex_name -v +``` +Expected: FAIL — `active_view` not in state, and `tmux-web` is still in `_default_state_dir` + +**Step 3: Apply the changes to `muxplex/state.py`** + +Change 1 — Update `_default_state_dir` (line 40): +```python +# Before: +_default_state_dir = Path.home() / ".local" / "share" / "tmux-web" + +# After: +_default_state_dir = Path.home() / ".local" / "share" / "muxplex" +``` + +Change 2 — Keep env var name for backward compatibility but update the variable name: +```python +# Before: +STATE_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir)) + +# After: +STATE_DIR: Path = Path(os.environ.get("MUXPLEX_STATE_DIR", os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir))) +``` + +Change 3 — Add `active_view` to `empty_state()` (line 60-66): +```python +def empty_state() -> dict: + """Return a fresh, empty top-level state dict. + + Every call returns a fully independent object — no shared mutables. + """ + return { + "active_session": None, + "active_remote_id": None, + "active_view": "all", + "session_order": [], + "sessions": {}, + "devices": {}, + } +``` + +Change 4 — Update the module docstring to include `active_view` in the schema (top of file, add after `active_remote_id` in the schema comment): +```python +# "active_view": str, # "all" | "hidden" | view name +``` + +**Step 4: Run all state tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_state.py -v +``` +Expected: All tests PASS (including the 3 new ones and all existing ones) + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/state.py muxplex/tests/test_state.py && git commit -m "feat: migrate state path to muxplex, add active_view field" +``` + +--- + +## Task 3: Update `settings.py` — Add `views` to Settings + Remove `filtered` from `gridViewMode` + +**Files:** +- Modify: `muxplex/settings.py` +- Modify: `muxplex/tests/test_settings.py` + +**Step 1: Write the failing tests** + +Add to the end of `muxplex/tests/test_settings.py`: + +```python +# --------------------------------------------------------------------------- +# views in DEFAULT_SETTINGS and SYNCABLE_KEYS +# --------------------------------------------------------------------------- + + +def test_views_in_default_settings(): + """DEFAULT_SETTINGS must include 'views' as an empty list.""" + assert "views" in DEFAULT_SETTINGS + assert DEFAULT_SETTINGS["views"] == [] + + +def test_views_in_syncable_keys(): + """'views' must be in SYNCABLE_KEYS so it syncs across federation.""" + assert "views" in SYNCABLE_KEYS + + +def test_views_roundtrip_through_save_and_load(tmp_path, monkeypatch): + """Views data survives a save/load cycle.""" + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + + views_data = [ + {"name": "Work", "sessions": ["abc:dev-server", "def:monitoring"]}, + {"name": "Hobby", "sessions": ["abc:3d-printer"]}, + ] + save_settings({"views": views_data}) + result = load_settings() + assert result["views"] == views_data + + +def test_patch_settings_syncs_views(tmp_path, monkeypatch): + """Patching 'views' bumps settings_updated_at (because views is in SYNCABLE_KEYS).""" + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + + result = patch_settings({"views": [{"name": "Test", "sessions": []}]}) + assert result["views"] == [{"name": "Test", "sessions": []}] + assert result["settings_updated_at"] > 0 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py::test_views_in_default_settings muxplex/tests/test_settings.py::test_views_in_syncable_keys -v +``` +Expected: FAIL — `views` not in DEFAULT_SETTINGS or SYNCABLE_KEYS + +**Step 3: Apply the changes to `muxplex/settings.py`** + +Change 1 — Add `views` to `DEFAULT_SETTINGS` (after `"hidden_sessions": []` on line 24): +```python + "hidden_sessions": [], + "views": [], +``` + +Change 2 — Add `"views"` to `SYNCABLE_KEYS` (in the Session behavior section, after `"hidden_sessions"`): +```python + "hidden_sessions", + "views", +``` + +**Step 4: Run all settings tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py -v +``` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add views to DEFAULT_SETTINGS and SYNCABLE_KEYS" +``` + +--- + +## Task 4: Create `muxplex/views.py` — Mutual Exclusion Invariant Functions + +**Files:** +- Create: `muxplex/views.py` +- Create: `muxplex/tests/test_views.py` + +**Step 1: Write the failing tests** + +Create `muxplex/tests/test_views.py`: + +```python +""" +Tests for muxplex/views.py — views invariant enforcement. +""" + +import pytest + +from muxplex.views import ( + enforce_mutual_exclusion, + validate_view_name, +) + + +# --------------------------------------------------------------------------- +# enforce_mutual_exclusion +# --------------------------------------------------------------------------- + + +def test_enforce_removes_from_hidden_when_in_view(): + """If a session is in both hidden_sessions and a view, remove from hidden (favor visibility).""" + settings = { + "hidden_sessions": ["abc:dev", "def:build"], + "views": [ + {"name": "Work", "sessions": ["abc:dev", "abc:web"]}, + ], + } + result = enforce_mutual_exclusion(settings) + assert "abc:dev" not in result["hidden_sessions"] + assert "def:build" in result["hidden_sessions"] + assert "abc:dev" in result["views"][0]["sessions"] + + +def test_enforce_no_change_when_no_overlap(): + """No changes when there is no overlap between hidden and views.""" + settings = { + "hidden_sessions": ["abc:old"], + "views": [ + {"name": "Work", "sessions": ["abc:dev"]}, + ], + } + result = enforce_mutual_exclusion(settings) + assert result["hidden_sessions"] == ["abc:old"] + assert result["views"][0]["sessions"] == ["abc:dev"] + + +def test_enforce_handles_empty_views(): + """Works when views is an empty list.""" + settings = { + "hidden_sessions": ["abc:dev"], + "views": [], + } + result = enforce_mutual_exclusion(settings) + assert result["hidden_sessions"] == ["abc:dev"] + + +def test_enforce_handles_empty_hidden(): + """Works when hidden_sessions is empty.""" + settings = { + "hidden_sessions": [], + "views": [{"name": "Work", "sessions": ["abc:dev"]}], + } + result = enforce_mutual_exclusion(settings) + assert result["hidden_sessions"] == [] + + +def test_enforce_deduplicates_view_sessions(): + """Duplicate session keys within a view are deduplicated.""" + settings = { + "hidden_sessions": [], + "views": [ + {"name": "Work", "sessions": ["abc:dev", "abc:dev", "abc:web"]}, + ], + } + result = enforce_mutual_exclusion(settings) + assert result["views"][0]["sessions"] == ["abc:dev", "abc:web"] + + +def test_enforce_overlap_across_multiple_views(): + """A hidden session appearing in multiple views is removed from hidden.""" + settings = { + "hidden_sessions": ["abc:dev"], + "views": [ + {"name": "Work", "sessions": ["abc:dev"]}, + {"name": "Hobby", "sessions": ["abc:dev", "abc:printer"]}, + ], + } + result = enforce_mutual_exclusion(settings) + assert "abc:dev" not in result["hidden_sessions"] + + +# --------------------------------------------------------------------------- +# validate_view_name +# --------------------------------------------------------------------------- + + +def test_validate_rejects_empty_name(): + assert validate_view_name("", []) is not None + + +def test_validate_rejects_whitespace_only(): + assert validate_view_name(" ", []) is not None + + +def test_validate_rejects_too_long(): + assert validate_view_name("a" * 31, []) is not None + + +def test_validate_rejects_reserved_all(): + assert validate_view_name("all", []) is not None + + +def test_validate_rejects_reserved_hidden(): + assert validate_view_name("Hidden", []) is not None + + +def test_validate_rejects_duplicate(): + existing = [{"name": "Work", "sessions": []}] + assert validate_view_name("Work", existing) is not None + + +def test_validate_accepts_valid_name(): + assert validate_view_name("My Project", []) is None + + +def test_validate_trims_whitespace(): + """A name that is valid after trimming should pass.""" + assert validate_view_name(" My Project ", []) is None + + +def test_validate_accepts_at_max_length(): + assert validate_view_name("a" * 30, []) is None +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_views.py -v +``` +Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.views'` + +**Step 3: Write the implementation** + +Create `muxplex/views.py`: + +```python +""" +Views invariant enforcement and validation for muxplex. + +Core invariants: +- hidden_sessions and any views[].sessions never share a session key. +- View names are non-empty, max 30 chars, trimmed, unique, not reserved. +- Duplicate session keys within a view are deduplicated. +""" + +RESERVED_VIEW_NAMES = frozenset({"all", "hidden"}) +MAX_VIEW_NAME_LENGTH = 30 + + +def enforce_mutual_exclusion(settings: dict) -> dict: + """Enforce that hidden_sessions and view sessions are disjoint. + + If a session key appears in both hidden_sessions and any view, + it is removed from hidden_sessions (favor visibility over hiding). + + Also deduplicates session keys within each view. + + Mutates and returns the settings dict. + """ + views = settings.get("views", []) + hidden = settings.get("hidden_sessions", []) + + # Collect all session keys across all views + all_view_sessions: set[str] = set() + for view in views: + all_view_sessions.update(view.get("sessions", [])) + + # Remove overlap from hidden (favor visibility) + if all_view_sessions and hidden: + settings["hidden_sessions"] = [ + s for s in hidden if s not in all_view_sessions + ] + + # Deduplicate session keys within each view (preserve order) + for view in views: + sessions = view.get("sessions", []) + seen: set[str] = set() + deduped: list[str] = [] + for s in sessions: + if s not in seen: + seen.add(s) + deduped.append(s) + view["sessions"] = deduped + + return settings + + +def validate_view_name(name: str, existing_views: list[dict]) -> str | None: + """Validate a view name. Returns an error message string, or None if valid. + + Rules: + - Non-empty after trimming + - Max 30 characters after trimming + - Not a reserved name ("all", "hidden") case-insensitive + - Unique among existing views (case-sensitive match) + """ + trimmed = name.strip() + if not trimmed: + return "View name cannot be empty" + if len(trimmed) > MAX_VIEW_NAME_LENGTH: + return f"View name must be {MAX_VIEW_NAME_LENGTH} characters or fewer" + if trimmed.lower() in RESERVED_VIEW_NAMES: + return f"'{trimmed}' is a reserved name" + existing_names = {v.get("name", "") for v in existing_views} + if trimmed in existing_names: + return f"A view named '{trimmed}' already exists" + return None +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_views.py -v +``` +Expected: All 15 tests PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/views.py muxplex/tests/test_views.py && git commit -m "feat: add views.py with mutual exclusion invariant and name validation" +``` + +--- + +## Task 5: Extend `/api/instance-info` to Return `device_id` + +**Files:** +- Modify: `muxplex/main.py` (lines 866–880) +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`, after the existing instance-info tests (after line ~1293): + +```python +def test_instance_info_includes_device_id(client, tmp_path, monkeypatch): + """GET /api/instance-info returns a device_id field.""" + import muxplex.identity as identity_mod + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", tmp_path / "identity.json") + + response = client.get("/api/instance-info") + assert response.status_code == 200 + data = response.json() + assert "device_id" in data, f"Response must include 'device_id', got: {data}" + # Must be a non-empty string + assert isinstance(data["device_id"], str) and len(data["device_id"]) > 0 +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_instance_info_includes_device_id -v +``` +Expected: FAIL — `device_id` not in response + +**Step 3: Apply the change to `muxplex/main.py`** + +Change 1 — Add import at the top of main.py (in the imports section, after the settings imports around line 67–73): +```python +from muxplex.identity import load_device_id +``` + +Change 2 — Modify the `instance_info()` function (line 866–880): +```python +@app.get("/api/instance-info") +async def instance_info() -> dict: + """Return this instance's display name, version, and device identity. + + Public endpoint (no auth required) — used by remote instances to + discover peer names, verify reachability, and obtain device_id. + """ + settings = load_settings() + # Read fresh so the UI reflects key-file changes without requiring a restart. + fed_key = load_federation_key() + return { + "name": settings["device_name"], + "device_id": load_device_id(), + "version": app.version, + "federation_enabled": bool(fed_key), + } +``` + +**Step 4: Run the test to verify it passes** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_instance_info_includes_device_id -v +``` +Expected: PASS + +**Step 5: Run the full instance-info test suite to check for regressions** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py -k "instance_info" -v +``` +Expected: All instance-info tests PASS + +**Step 6: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: include device_id in /api/instance-info response" +``` + +--- + +## Task 6: Add `--reset-device-id` CLI Command + +**Files:** +- Modify: `muxplex/cli.py` +- Modify: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# reset-device-id subcommand tests +# --------------------------------------------------------------------------- + + +def test_reset_device_id_writes_new_id(tmp_path, monkeypatch, capsys): + """reset_device_id_command() generates a new device_id and prints confirmation.""" + import json + + import muxplex.identity as identity_mod + from muxplex.cli import reset_device_id_command + + fake_path = tmp_path / "identity.json" + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", fake_path) + + # Create an initial identity + original_id = identity_mod.load_device_id() + + # Reset it + reset_device_id_command() + + # File must have a different device_id + data = json.loads(fake_path.read_text()) + assert data["device_id"] != original_id + + # Output must mention warning about orphaned keys + captured = capsys.readouterr() + assert "device_id" in captured.out.lower() or "identity" in captured.out.lower() + assert "warning" in captured.out.lower() or "orphan" in captured.out.lower() + + +def test_main_dispatches_to_reset_device_id(monkeypatch): + """main() with 'reset-device-id' subcommand must invoke reset_device_id_command().""" + import muxplex.cli as cli_mod + + calls = [] + monkeypatch.setattr(cli_mod, "reset_device_id_command", lambda: calls.append(True)) + with patch("sys.argv", ["muxplex", "reset-device-id"]): + cli_mod.main() + assert calls, ( + "reset_device_id_command() must be called for 'reset-device-id' subcommand" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_reset_device_id_writes_new_id muxplex/tests/test_cli.py::test_main_dispatches_to_reset_device_id -v +``` +Expected: FAIL — `ImportError: cannot import name 'reset_device_id_command'` + +**Step 3: Apply the changes to `muxplex/cli.py`** + +Change 1 — Add the function (after the `reset_secret()` function, around line 150): +```python +def reset_device_id_command() -> None: + """Regenerate the device identity UUID and warn about orphaned session keys.""" + from muxplex.identity import IDENTITY_PATH, load_device_id, reset_device_id + + old_id = None + try: + old_id = load_device_id() + except Exception: + pass + + new_id = reset_device_id() + print(f"New device_id: {new_id}") + print(f"Identity file: {IDENTITY_PATH}") + if old_id: + print(f"Previous device_id: {old_id}") + print("Warning: session keys in views and hidden_sessions that referenced") + print("the old device_id are now orphaned and will not match this instance.") +``` + +Change 2 — Register the subparser (after the `reset-secret` parser registration, around line 954): +```python + sub.add_parser( + "reset-device-id", + help="Regenerate device identity UUID (orphans existing session keys)", + ) +``` + +Change 3 — Add dispatch (after the `reset-secret` dispatch, around line 1007): +```python + elif args.command == "reset-device-id": + reset_device_id_command() +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_cli.py::test_reset_device_id_writes_new_id muxplex/tests/test_cli.py::test_main_dispatches_to_reset_device_id -v +``` +Expected: Both PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat: add --reset-device-id CLI command" +``` + +--- + +## Task 7: Wire Post-Sync Invariant Repair into Settings Sync + +**Files:** +- Modify: `muxplex/settings.py` +- Modify: `muxplex/tests/test_settings.py` + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_settings.py`: + +```python +def test_apply_synced_settings_enforces_mutual_exclusion(tmp_path, monkeypatch): + """apply_synced_settings() runs mutual exclusion repair after applying synced data.""" + import json + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + + # Pre-populate settings with a hidden session + save_settings({"hidden_sessions": ["abc:dev"], "views": []}) + + # Incoming sync adds a view containing the hidden session + incoming = { + "views": [{"name": "Work", "sessions": ["abc:dev"]}], + "hidden_sessions": ["abc:dev"], + } + result = apply_synced_settings(incoming, 999.0) + + # Mutual exclusion: abc:dev should be removed from hidden_sessions + assert "abc:dev" not in result["hidden_sessions"] + assert "abc:dev" in result["views"][0]["sessions"] +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py::test_apply_synced_settings_enforces_mutual_exclusion -v +``` +Expected: FAIL — `abc:dev` still in `hidden_sessions` + +**Step 3: Apply the change to `muxplex/settings.py`** + +Change the `apply_synced_settings` function (line 162–174) to call `enforce_mutual_exclusion` after applying synced keys: + +```python +def apply_synced_settings(incoming_settings: dict, incoming_timestamp: float) -> dict: + """Apply synced settings from a remote server. + + Only applies keys that are in SYNCABLE_KEYS. Sets settings_updated_at + to the incoming timestamp (NOT time.time()) to prevent sync loops. + Runs mutual exclusion invariant repair after applying. + """ + from muxplex.views import enforce_mutual_exclusion + + current = load_settings() + for key in SYNCABLE_KEYS: + if key in incoming_settings: + current[key] = incoming_settings[key] + current["settings_updated_at"] = incoming_timestamp + enforce_mutual_exclusion(current) + save_settings(current) + return current +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py -v +``` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: run mutual exclusion invariant repair after settings sync" +``` + +--- + +## Task 8: Federation Proxy — Helper Function to Look Up Remote by `device_id` + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +This task adds a shared helper function that looks up a remote instance by `device_id` instead of integer index. Tasks 9 and 10 will use this helper to rewrite the federation endpoints. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# _lookup_remote_by_device_id helper +# --------------------------------------------------------------------------- + + +def test_lookup_remote_by_device_id_found(tmp_path, monkeypatch): + """_lookup_remote_by_device_id returns the remote dict when device_id matches.""" + import json + + import muxplex.main as main_mod + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + {"url": "https://pi:8088", "name": "pi", "device_id": "aaa-111"}, + {"url": "https://desktop:8088", "name": "desktop", "device_id": "bbb-222"}, + ] + } + ) + ) + + remote = main_mod._lookup_remote_by_device_id("bbb-222") + assert remote is not None + assert remote["name"] == "desktop" + + +def test_lookup_remote_by_device_id_not_found(tmp_path, monkeypatch): + """_lookup_remote_by_device_id returns None when no remote matches.""" + import json + + import muxplex.main as main_mod + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + {"url": "https://pi:8088", "name": "pi", "device_id": "aaa-111"}, + ] + } + ) + ) + + remote = main_mod._lookup_remote_by_device_id("zzz-999") + assert remote is None +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_lookup_remote_by_device_id_found muxplex/tests/test_api.py::test_lookup_remote_by_device_id_not_found -v +``` +Expected: FAIL — `AttributeError: module 'muxplex.main' has no attribute '_lookup_remote_by_device_id'` + +**Step 3: Add the helper function to `muxplex/main.py`** + +Add this function before the federation proxy section (before the `federation_terminal_ws_proxy` function, around line 1008): + +```python +def _lookup_remote_by_device_id(device_id: str) -> dict | None: + """Look up a remote instance by device_id. + + Returns the remote dict from remote_instances if found, None otherwise. + Supports both the new device_id-based lookup and falls back to integer + index lookup during the transition period. + """ + settings = load_settings() + remotes = settings.get("remote_instances", []) + + # Primary: match by device_id field + for remote in remotes: + if remote.get("device_id") == device_id: + return remote + + # Fallback: if device_id looks like an integer, try index-based lookup + # (transition compatibility for old-format URLs) + try: + idx = int(device_id) + if 0 <= idx < len(remotes): + return remotes[idx] + except (ValueError, TypeError): + pass + + return None +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_lookup_remote_by_device_id_found muxplex/tests/test_api.py::test_lookup_remote_by_device_id_not_found -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 _lookup_remote_by_device_id helper for federation proxy" +``` + +--- + +## Task 9: Federation Proxy Endpoints — Switch to `device_id` Lookup + +**Files:** +- Modify: `muxplex/main.py` (federation_connect, federation_bell_clear, federation_create_session, federation_delete_session, federation_terminal_ws_proxy) +- Modify: `muxplex/tests/test_api.py` + +This task changes all federation proxy URL patterns from `/api/federation/{remote_id:int}/...` to `/api/federation/{device_id}/...` and uses `_lookup_remote_by_device_id()` instead of array indexing. The integer fallback in the helper ensures backward compatibility during migration. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_federation_connect_by_device_id(client, tmp_path, monkeypatch): + """POST /api/federation/{device_id}/connect/{session} accepts device_id strings.""" + import json + + import httpx + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps( + { + "remote_instances": [ + { + "url": "https://pi:8088", + "name": "pi", + "key": "test-key", + "device_id": "aaa-111-bbb", + } + ] + } + ) + ) + + # Mock the federation client to return a success response + async def mock_post(url, **kwargs): + resp = httpx.Response(200, json={"status": "connected"}) + return resp + + monkeypatch.setattr( + client.app.state, "federation_client", type("MockClient", (), {"post": mock_post})() + ) + + response = client.post("/api/federation/aaa-111-bbb/connect/my-session") + assert response.status_code == 200 + + +def test_federation_connect_device_id_not_found(client, tmp_path, monkeypatch): + """POST /api/federation/{device_id}/connect/{session} returns 404 for unknown device_id.""" + import json + + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text(json.dumps({"remote_instances": []})) + + response = client.post("/api/federation/nonexistent-device/connect/my-session") + assert response.status_code == 404 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_connect_by_device_id muxplex/tests/test_api.py::test_federation_connect_device_id_not_found -v +``` +Expected: FAIL — the endpoint expects `int` type for `remote_id`, not a UUID string. The request will fail with a 422 validation error. + +**Step 3: Apply the changes to `muxplex/main.py`** + +For each of the four federation HTTP proxy endpoints, change: +1. The route path parameter from `{remote_id}` to `{device_id}` +2. The function parameter from `remote_id: int` to `device_id: str` +3. The lookup from `remotes[remote_id]` to `_lookup_remote_by_device_id(device_id)` + +**federation_connect** (line ~1337): +```python +@app.post("/api/federation/{device_id}/connect/{session_name}") +async def federation_connect( + device_id: str, session_name: str, request: Request +) -> dict: + """Proxy a connect POST to a remote instance to spawn its ttyd. + + Looks up the remote by device_id in remote_instances settings, + sends POST {remote_url}/api/sessions/{session_name}/connect with a + Bearer auth header, and returns the remote's JSON response. + + Raises HTTP 404 if device_id is not found in remote_instances. + """ + remote = _lookup_remote_by_device_id(device_id) + if remote is None: + raise HTTPException( + status_code=404, + detail=f"Remote instance '{device_id}' not found", + ) + + remote_url: str = remote.get("url", "").rstrip("/") + remote_key: str = remote.get("key", "") + url = f"{remote_url}/api/sessions/{session_name}/connect" + + http_client: httpx.AsyncClient = request.app.state.federation_client + try: + resp = await http_client.post( + url, + headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {}, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPStatusError as exc: + raise HTTPException( + status_code=502, + detail=f"Remote returned {exc.response.status_code}", + ) + except Exception as exc: + _log.warning("federation_connect: remote %s unreachable: %s", remote_url, exc) + raise HTTPException( + status_code=503, + detail=f"Remote unreachable: {remote_url} ({type(exc).__name__}: {exc})", + ) +``` + +Apply the same pattern to: + +**federation_bell_clear** (line ~1383): Change route to `"/api/federation/{device_id}/sessions/{session_name}/bell/clear"`, parameter to `device_id: str`, lookup to `_lookup_remote_by_device_id(device_id)` with the same None → 404 pattern. + +**federation_create_session** (line ~1431): Change route to `"/api/federation/{device_id}/sessions"`, parameter to `device_id: str`, lookup to `_lookup_remote_by_device_id(device_id)` with the same None → 404 pattern. + +**federation_delete_session** (line ~1479): Change route to `"/api/federation/{device_id}/sessions/{session_name}"`, parameter to `device_id: str`, lookup to `_lookup_remote_by_device_id(device_id)` with the same None → 404 pattern. + +**federation_terminal_ws_proxy** (line ~1011): Change route to `"/federation/{device_id}/terminal/ws"`, parameter to `device_id: str`, and change the lookup logic from array indexing to: +```python + # Look up remote instance by device_id + remote = _lookup_remote_by_device_id(device_id) + if remote is None: + await websocket.close(code=4004) + return +``` +Remove the old `settings = load_settings()` / `remote_instances` / bounds-check block and replace with the above. + +**Step 4: Run the new tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_connect_by_device_id muxplex/tests/test_api.py::test_federation_connect_device_id_not_found -v +``` +Expected: Both PASS + +**Step 5: Run the full API test suite to check for regressions** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py -v --timeout=60 +``` +Expected: All tests PASS. Existing federation tests that use integer indices will still work because `_lookup_remote_by_device_id` has an integer fallback. + +**Step 6: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: switch federation proxy endpoints from integer index to device_id lookup" +``` + +--- + +## Task 10: Update `fetch_remote()` to Tag Sessions with `device_id` + +**Files:** +- Modify: `muxplex/main.py` (the `fetch_remote` inner function and `_federation_cache`, lines ~1185–1304) +- Modify: `muxplex/tests/test_api.py` + +This task changes the `federation_sessions` endpoint to tag each remote session with `device_id` instead of integer `remoteId`, and to generate `sessionKey` as `device_id:name` instead of `remoteId:name`. Local sessions also get tagged with the local device_id. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_api.py`: + +```python +def test_federation_sessions_tags_local_with_device_id(client, tmp_path, monkeypatch): + """GET /api/federation/sessions includes deviceId for local sessions.""" + import json + + import muxplex.identity as identity_mod + import muxplex.main as main_mod + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", tmp_path / "identity.json") + # Write identity file + (tmp_path / "identity.json").write_text(json.dumps({"device_id": "local-uuid"})) + + # Mock get_session_list to return one session + monkeypatch.setattr(main_mod, "get_session_list", lambda: ["dev"]) + monkeypatch.setattr(main_mod, "get_snapshots", lambda: {}) + + response = client.get("/api/federation/sessions") + assert response.status_code == 200 + data = response.json() + local_sessions = [s for s in data if s.get("name") == "dev"] + assert len(local_sessions) == 1 + assert local_sessions[0].get("deviceId") == "local-uuid" + assert local_sessions[0].get("sessionKey") == "local-uuid:dev" +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_sessions_tags_local_with_device_id -v +``` +Expected: FAIL — local sessions don't have `deviceId` or the new `sessionKey` format + +**Step 3: Apply the changes to `muxplex/main.py`** + +Change 1 — In the `federation_sessions` function (line ~1192), add local device_id import and tagging: + +```python +@app.get("/api/federation/sessions") +async def federation_sessions(request: Request) -> list[dict]: + """Fetch sessions from all instances (local + remotes) and merge. + + Local sessions are tagged with deviceName, deviceId, and sessionKey. + Remote sessions are fetched concurrently via asyncio.gather with Bearer auth + headers. Failed remotes produce a status entry with status='unreachable' or + status='auth_failed'. + """ + settings = load_settings() + local_device_name: str = settings.get("device_name", "") + local_device_id: str = load_device_id() + remote_instances: list[dict] = settings.get("remote_instances", []) + + # Build local sessions with deviceName/deviceId/sessionKey tags + names = get_session_list() + snapshots = get_snapshots() + state = await read_state() + local_sessions: list[dict] = [] + for name in names: + session_state = state.get("sessions", {}).get(name, {}) + bell = session_state.get("bell", empty_bell()) + local_sessions.append( + { + "name": name, + "snapshot": snapshots.get(name, ""), + "bell": bell, + "deviceName": local_device_name, + "deviceId": local_device_id, + "remoteId": None, + "sessionKey": f"{local_device_id}:{name}", + } + ) +``` + +Change 2 — In the `fetch_remote` inner function, use `device_id` from the remote instance for tagging instead of the integer index. Change the `remote_id: int = i` line and all references: + +```python + async def fetch_remote(i: int, remote: dict) -> list[dict]: + url: str = remote.get("url", "") + key: str = remote.get("key", "") + remote_name: str = remote.get("name", url) + remote_device_id: str = remote.get("device_id", str(i)) + # ... rest of function uses remote_device_id instead of remote_id +``` + +In the tagged session list comprehension: +```python + tagged = [ + { + **s, + "deviceName": remote_name, + "deviceId": remote_device_id, + "remoteId": remote_device_id, + "sessionKey": f"{remote_device_id}:{s.get('name', '')}", + } + for s in sessions + ] +``` + +In all status entries (`auth_failed`, `empty`, `unreachable`), change `"remoteId": remote_id` to `"remoteId": remote_device_id` and add `"deviceId": remote_device_id`. + +Change 3 — Update `_federation_cache` type hint from `dict[int, dict]` to `dict[str, dict]` (line ~1188): +```python +_federation_cache: dict[str, dict] = {} +``` + +And change all cache key references from `remote_id` to `remote_device_id`. + +**Step 4: Run the new test to verify it passes** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_federation_sessions_tags_local_with_device_id -v +``` +Expected: PASS + +**Step 5: Run the full API test suite** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py -v --timeout=60 +``` +Expected: All tests PASS. Existing tests that check `remoteId` will still pass because we kept the `remoteId` field (now containing device_id string instead of integer, but the existing tests that create mock remotes will work with the string fallback). + +> **Note to implementer:** Some existing federation tests may need minor adjustments if they assert `remoteId` is an integer. If a test fails, check if it's asserting `remoteId == 0` (integer) — change the assertion to match the new `device_id` string. The `_federation_cache` key type change may also require updating existing test expectations. + +**Step 6: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: tag sessions with device_id-based sessionKey in federation_sessions" +``` + +--- + +## Task 11: Frontend — Change Session Key Format to `device_id:name` + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/tests/test_frontend_js.py` + +The backend now sends `deviceId` and `sessionKey` in `device_id:name` format on every session object. The frontend needs to: +1. Use `session.deviceId` instead of `session.remoteId` for federation API calls +2. Use `session.sessionKey` (already present from the backend) instead of constructing keys locally +3. Update `data-remote-id` attributes to use `deviceId` +4. Update `createNewSession()` and `killSession()` to use `deviceId` in API URLs +5. Update `openSession()` to accept `deviceId` instead of `remoteId` +6. Update state patches to use `deviceId` for `active_remote_id` + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_frontend_js.py` (find the section for session key tests or add at the end): + +```python +def test_session_tile_uses_device_id_in_data_attribute(page): + """Session tiles use deviceId in data-remote-id attribute.""" + page.evaluate("""() => { + const app = window.MuxplexApp; + app._setServerSettings({ + hidden_sessions: [], + sort_order: 'manual', + }); + app._setCurrentSessions([ + { name: 'dev', deviceName: 'pi', deviceId: 'abc-123', remoteId: 'abc-123', sessionKey: 'abc-123:dev', bell: { unseen_count: 0 } }, + ]); + }""") + # Render the grid + page.evaluate("() => window.MuxplexApp.renderGrid()") + tile = page.query_selector('[data-session="dev"]') + assert tile is not None + remote_id_attr = tile.get_attribute("data-remote-id") + assert remote_id_attr == "abc-123" +``` + +> **Note to implementer:** The frontend JS test file uses a specific test framework. Look at the top of `muxplex/tests/test_frontend_js.py` to understand the exact test setup pattern (it may use `subprocess` to run Node tests, or pytest with a browser fixture). Match whatever pattern exists. If the test file uses Node.js + jsdom or a similar approach, adapt the test accordingly. The assertion content is what matters — verify `data-remote-id` uses the `deviceId` string. + +**Step 2: Run the test to verify it fails** + +Run the existing frontend JS test suite first to understand the baseline: +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "device_id" -v +``` + +**Step 3: Apply the changes to `muxplex/frontend/app.js`** + +This is a large set of find-and-replace changes. The key transformations: + +Change 1 — In `createNewSession()` (line ~2089–2092), replace remoteId with deviceId in the API URL construction: +```javascript +// Before: + remoteId = remoteId || ''; + try { + var endpoint = remoteId ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions' : '/api/sessions'; + +// After: + var deviceId = remoteId || ''; // Accept either name during transition + try { + var endpoint = deviceId ? '/api/federation/' + encodeURIComponent(deviceId) + '/sessions' : '/api/sessions'; +``` + +Change 2 — In `openSession()` (line ~1228–1232), use deviceId for the federation connect URL: +```javascript +// Before: + var _remoteId = opts.remoteId != null ? opts.remoteId : ''; + try { + if (_remoteId !== '') { + +// After: + var _deviceId = opts.remoteId != null ? opts.remoteId : ''; + try { + if (_deviceId !== '') { +``` +And update the `/api/federation/` URL to use `_deviceId`. + +Change 3 — In `killSession()` function, update the federation delete URL to use `deviceId`: +Find the federation delete endpoint URL construction and change `remoteId` to `deviceId` from the session data. + +Change 4 — In `getVisibleSessions()` (line ~537–547), update hidden session matching to use `sessionKey` instead of `name`: +```javascript +// Before: + if (hidden.length > 0 && hidden.includes(s.name)) { + +// After: + if (hidden.length > 0 && (hidden.includes(s.sessionKey || s.name) || hidden.includes(s.name))) { +``` +This provides backward compatibility — `hidden_sessions` may contain either old format (plain name) or new format (`device_id:name`). + +Change 5 — In the `_viewingRemoteId` state variable and its usage, rename conceptually to represent device_id. Since this is used throughout the file, the simplest approach is to keep the variable name `_viewingRemoteId` but ensure it stores the `deviceId` value. The `data-remote-id` attributes already work because the backend now sends `deviceId` as the `remoteId` value. + +Change 6 — In the `PATCH /api/state` call that sets `active_remote_id`, ensure it sends the device_id string: +```javascript +// The value stored is already opts.remoteId which now contains deviceId +// No code change needed if the backend accepts strings for active_remote_id +``` + +> **Important:** The backend's `StatePatch` model has `active_remote_id: str | None` (line 428 of main.py), so it already accepts strings. No backend change needed here. + +**Step 4: Run the frontend tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -v --timeout=120 +``` +Expected: All tests PASS. The `deviceId` field is backward-compatible because the backend now sends it alongside `remoteId`. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: update frontend to use device_id-based session keys and API URLs" +``` + +--- + +## Task 12: Remove `filtered` from `gridViewMode` Options + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/tests/test_frontend_js.py` + +The design removes `filtered` as a `gridViewMode` value. Only `flat` and `grouped` remain. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_grid_view_mode_filtered_not_available(page): + """gridViewMode 'filtered' should not be recognized — defaults to 'flat'.""" + page.evaluate("""() => { + const app = window.MuxplexApp; + app._setGridViewMode('filtered'); + }""") + mode = page.evaluate("() => window.MuxplexApp._getGridViewMode()") + # After removing filtered, setting it should fall back to 'flat' + assert mode == "flat" +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "filtered_not_available" -v +``` +Expected: FAIL — `_setGridViewMode('filtered')` currently accepts the value + +**Step 3: Apply the changes to `muxplex/frontend/app.js`** + +Change 1 — Remove all `filtered`-specific code paths in `renderGrid()` (lines ~761-763, ~780-784, ~822-823): + +Remove: +```javascript + // In filtered mode, apply device filter + if (_gridViewMode === 'filtered' && _activeFilterDevice !== 'all') { + visible = visible.filter(function(s) { return s.deviceName === _activeFilterDevice; }); + } +``` + +Remove all `if (_gridViewMode === 'filtered')` blocks that render the filter bar. + +Change 2 — In `loadGridViewMode()` (line ~1547), add a guard: +```javascript +function loadGridViewMode() { + var ds = getDisplaySettings(); + var mode = ds.gridViewMode || 'flat'; + // 'filtered' was removed in the Views feature — fall back to 'flat' + if (mode === 'filtered') mode = 'flat'; + return mode; +} +``` + +Change 3 — In `_setGridViewMode()` test helper, add the same guard: +```javascript +function _setGridViewMode(mode) { + if (mode === 'filtered') mode = 'flat'; + _gridViewMode = mode; +} +``` + +Change 4 — In `DISPLAY_DEFAULTS` (line ~149), the comment already says `'flat' | 'grouped'`. No change needed there. + +Change 5 — Remove the `_activeFilterDevice` state variable and `renderFilterBar` function, or leave them as dead code for now (they'll be removed when the filter bar HTML is removed in Phase 2). The safer approach is to leave them and let Phase 2 clean up the HTML. Just ensure `_gridViewMode` never gets set to `'filtered'`. + +**Step 4: Run the frontend tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -v --timeout=120 +``` +Expected: All tests PASS. Tests that explicitly test `filtered` mode should be updated to expect `flat` fallback behavior. + +> **Note to implementer:** If existing tests assert that setting gridViewMode to 'filtered' works, update those tests to expect 'flat' instead. Search for `'filtered'` in `test_frontend_js.py`. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: remove 'filtered' gridViewMode, keep only flat and grouped" +``` + +--- + +## 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 data flow end-to-end** + +Check that the full chain works by listing what Phase 1 established: + +1. `identity.py` creates/loads device_id from `~/.config/muxplex/identity.json` ✓ +2. `state.py` uses `~/.local/share/muxplex/` and includes `active_view` ✓ +3. `settings.py` includes `views` in DEFAULT_SETTINGS and SYNCABLE_KEYS ✓ +4. `views.py` enforces mutual exclusion between hidden and view sessions ✓ +5. `/api/instance-info` returns `device_id` ✓ +6. `--reset-device-id` CLI command works ✓ +7. Post-sync invariant repair runs after `apply_synced_settings()` ✓ +8. Federation proxy endpoints accept `device_id` strings ✓ +9. `federation_sessions` tags all sessions with `device_id:name` keys ✓ +10. Frontend uses `deviceId` for API calls and session keys ✓ +11. `filtered` gridViewMode removed ✓ + +**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 1 integration fixes" +``` + +--- + +## Deferred to Phase 2 + +The following are explicitly NOT in Phase 1: + +- **Session key migration logic** — rewriting old positional `remoteId:name` keys in `hidden_sessions` and `session_order` to `device_id:name`. The integer fallback in `_lookup_remote_by_device_id()` provides backward compatibility, so migration can happen in Phase 2 when the remote's `device_id` is first discovered via `/api/instance-info`. +- **Header dropdown UI** for view switching +- **Tile flyout menu** (`⋮` button) +- **Add Sessions panel** +- **`getVisibleSessions()` rewrite** to filter by active view +- **Settings dialog** — Manage Views tab +- **Mobile variants** — bottom sheets +- **Config path migration** (moving files from `~/.local/share/tmux-web/` to `~/.local/share/muxplex/`) — the `MUXPLEX_STATE_DIR` env var and the fallback to `TMUX_WEB_STATE_DIR` provide compatibility. Actual file migration (copying old state.json to new location) can be added as a startup step in Phase 2 once the identity system is stable. \ No newline at end of file diff --git a/docs/plans/2026-04-15-views-phase2-implementation.md b/docs/plans/2026-04-15-views-phase2-implementation.md new file mode 100644 index 0000000..1bfb7aa --- /dev/null +++ b/docs/plans/2026-04-15-views-phase2-implementation.md @@ -0,0 +1,2037 @@ +# 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/`) \ No newline at end of file diff --git a/docs/plans/2026-04-15-views-phase3-implementation.md b/docs/plans/2026-04-15-views-phase3-implementation.md new file mode 100644 index 0000000..c514c71 --- /dev/null +++ b/docs/plans/2026-04-15-views-phase3-implementation.md @@ -0,0 +1,2236 @@ +# Views Feature — Phase 3: Tile Flyout Menu + Add Sessions Panel + Final Integration + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Build the tile-level interaction UI (flyout `⋮` menu with context-dependent actions, inline kill confirmation) and the Add Sessions panel, then verify all three phases work together end-to-end. + +**Architecture:** An always-visible `⋮` button on every session tile opens a floating flyout menu (appended to `document.body` with `position: fixed` and JS-calculated coordinates to avoid z-index/stacking issues). Menu items are generated from a data map keyed by view type (`all`, `user`, `hidden`) — not if/else chains. The flyout replaces the old `confirm()` kill dialog and `.tile-delete` button entirely. A separate Add Sessions panel is an overlay for bulk-adding sessions to a user view with immediate-commit checkboxes. On mobile (`window.innerWidth < 600`), the flyout renders as a bottom action sheet and the Add Sessions panel renders as a full-screen sheet. + +**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` +**Phase 2 reference:** `docs/plans/2026-04-15-views-phase2-implementation.md` + +**Assumes Phase 1+2 are complete:** `identity.py` exists, session keys use `device_id:name`, `views` is in `DEFAULT_SETTINGS` and `SYNCABLE_KEYS`, `active_view` is in state schema, `views.py` has `enforce_mutual_exclusion()` and `validate_view_name()`, header dropdown works with `renderViewDropdown()` / `toggleViewDropdown()` / `switchView()`, Manage Views tab exists in settings, `getVisibleSessions()` honors active view, `_activeView` state variable exists, `filtered` gridViewMode removed, hidden sessions checkbox removed from settings, `_setActiveView` / `_getActiveView` test helpers exported. + +--- + +## Task 1: Flyout Menu Base Component — CSS + +**Files:** +- Modify: `muxplex/frontend/style.css` +- Test: `muxplex/tests/test_frontend_css.py` + +**Step 1: Write the failing tests** + +Add to the end of `muxplex/tests/test_frontend_css.py`: + +```python +# --------------------------------------------------------------------------- +# Tile flyout menu styles +# --------------------------------------------------------------------------- + + +def test_flyout_menu_styled() -> None: + """style.css must contain .flyout-menu styles.""" + css = read_css() + assert ".flyout-menu" in css, "style.css must style .flyout-menu" + + +def test_flyout_menu_item_styled() -> None: + """style.css must contain .flyout-menu__item styles.""" + css = read_css() + assert ".flyout-menu__item" in css, "style.css must style .flyout-menu__item" + + +def test_flyout_trigger_styled() -> None: + """style.css must contain .tile-options-btn styles.""" + css = read_css() + assert ".tile-options-btn" in css, "style.css must style .tile-options-btn" + + +def test_flyout_submenu_styled() -> None: + """style.css must contain .flyout-submenu styles.""" + css = read_css() + assert ".flyout-submenu" in css, "style.css must style .flyout-submenu" + + +def test_flyout_bottom_sheet_styled() -> None: + """style.css must contain .flyout-sheet styles for mobile.""" + css = read_css() + assert ".flyout-sheet" in css, "style.css must style .flyout-sheet (mobile bottom action sheet)" +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py::test_flyout_menu_styled -v +``` +Expected: FAIL — `.flyout-menu` not in CSS + +**Step 3: Add styles to `muxplex/frontend/style.css`** + +Append before the media query sections (before line ~1645). Insert after the views settings tab styles: + +```css +/* —— Tile Flyout Menu ————————————————————————————————————————————————— */ + +.tile-options-btn { + position: absolute; + top: 6px; + right: 6px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-tile); + border: 1px solid var(--border-subtle); + border-radius: 4px; + color: var(--text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + z-index: 2; + transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast); +} + +.tile-options-btn:hover, +.tile-options-btn:focus-visible { + border-color: var(--border); + color: var(--text); + background: var(--bg-surface); +} + +.flyout-menu { + position: fixed; + min-width: 200px; + max-width: 280px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 0; + z-index: 300; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} + +.flyout-menu__item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 12px; + background: transparent; + border: none; + color: var(--text); + font-size: 13px; + cursor: pointer; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; +} + +.flyout-menu__item:hover, +.flyout-menu__item:focus-visible { + background: var(--bg-surface); +} + +.flyout-menu__item--danger { + color: var(--err); +} + +.flyout-menu__item--danger:hover { + background: rgba(248, 81, 73, 0.1); +} + +.flyout-menu__item--has-submenu::after { + content: "\25B8"; + margin-left: auto; + font-size: 10px; + color: var(--text-dim); +} + +.flyout-menu__separator { + height: 1px; + margin: 4px 0; + background: var(--border-subtle); +} + +.flyout-menu__confirm { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 12px; + font-size: 13px; + color: var(--text-muted); +} + +.flyout-menu__confirm-btn { + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border); + background: transparent; + color: var(--text); + font-size: 12px; + cursor: pointer; +} + +.flyout-menu__confirm-btn:hover { + background: var(--bg-surface); +} + +.flyout-menu__confirm-btn--yes { + border-color: var(--err); + color: var(--err); +} + +.flyout-menu__confirm-btn--yes:hover { + background: rgba(248, 81, 73, 0.15); +} + +/* —— Flyout Submenu (Add to View) ————————————————————————————————————— */ + +.flyout-submenu { + position: fixed; + min-width: 180px; + max-width: 240px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 4px 0; + z-index: 310; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); +} + +.flyout-submenu__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; +} + +.flyout-submenu__item:hover { + background: var(--bg-surface); +} + +.flyout-submenu__check { + width: 14px; + flex-shrink: 0; + color: var(--accent); + font-size: 12px; +} + +/* —— Mobile: Flyout as bottom action sheet ————————————————————————————— */ + +.flyout-sheet { + position: fixed; + inset: 0; + z-index: 300; + display: flex; + align-items: flex-end; +} + +.flyout-sheet__backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay); +} + +.flyout-sheet__panel { + position: relative; + width: 100%; + background: var(--bg-header); + border-top: 1px solid var(--border); + border-radius: 12px 12px 0 0; + max-height: 70vh; + overflow-y: auto; + animation: sheet-up var(--t-zoom) ease; + padding-bottom: env(safe-area-inset-bottom, 8px); +} + +.flyout-sheet__handle { + width: 36px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin: 10px auto 6px; +} + +.flyout-sheet__item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 14px 20px; + background: transparent; + border: none; + color: var(--text); + font-size: 15px; + cursor: pointer; + text-align: left; +} + +.flyout-sheet__item:active { + background: var(--bg-surface); +} + +.flyout-sheet__item--danger { + color: var(--err); +} + +.flyout-sheet__separator { + height: 1px; + margin: 4px 0; + background: var(--border-subtle); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_css.py -k "flyout" -v +``` +Expected: All 5 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 flyout menu, submenu, and mobile bottom sheet CSS" +``` + +--- + +## Task 2: Add ⋮ Button to Session Tiles + Remove Old Kill Button + +**Files:** +- Modify: `muxplex/frontend/app.js` (the `buildTileHTML()` function, lines 409–458) +- Test: `muxplex/tests/test_frontend_js.py` + +The existing `.tile-delete` `×` button (line 455 of app.js) is replaced by a `⋮` options button. Kill session moves to the flyout menu (Task 7). + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +# --------------------------------------------------------------------------- +# Tile options button (⋮) replaces tile-delete +# --------------------------------------------------------------------------- + + +def test_tile_has_options_button() -> None: + """buildTileHTML must include a .tile-options-btn button.""" + fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] + assert "tile-options-btn" in fn_body, ( + "buildTileHTML must render a .tile-options-btn element" + ) + + +def test_tile_options_btn_has_aria() -> None: + """The ⋮ button must have aria-label='Session options' and aria-haspopup='true'.""" + fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] + assert 'aria-label' in fn_body and 'Session options' in fn_body, ( + "tile-options-btn must have aria-label='Session options'" + ) + assert 'aria-haspopup' in fn_body, ( + "tile-options-btn must have aria-haspopup='true'" + ) + + +def test_tile_delete_button_removed() -> None: + """buildTileHTML must NOT include the old .tile-delete button.""" + fn_body = _JS.split("function buildTileHTML")[1].split("\nfunction ")[0] + assert "tile-delete" not in fn_body, ( + "buildTileHTML must not render the old .tile-delete button (kill moved to flyout)" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_tile_has_options_button muxplex/tests/test_frontend_js.py::test_tile_delete_button_removed -v +``` +Expected: FAIL — `tile-options-btn` not in `buildTileHTML`, and `tile-delete` still present + +**Step 3: Apply the change to `muxplex/frontend/app.js`** + +In `buildTileHTML()` (line 455), replace the old `.tile-delete` button: + +```javascript +// Before (line 455): + `` + + +// After: + `` + +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "tile_has_options or tile_options_btn_has_aria or tile_delete_button_removed" -v +``` +Expected: All 3 PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/tests/test_frontend_js.py && git commit -m "feat: replace tile-delete button with ⋮ options button on session tiles" +``` + +--- + +## Task 3: Flyout Menu Base JS — Open, Position, Close, Event Delegation + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +Implement `openFlyoutMenu(sessionKey, triggerEl)`, `closeFlyoutMenu()`, and the delegated click listener on the tile container that opens the flyout when a `⋮` button is clicked. Also remove the old `.tile-delete` delegated click handler from `bindStaticEventListeners()` (lines 2185–2194). + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_open_flyout_menu_function_exists() -> None: + """app.js must define an openFlyoutMenu function.""" + assert "function openFlyoutMenu" in _JS, ( + "app.js must contain an openFlyoutMenu function" + ) + + +def test_close_flyout_menu_function_exists() -> None: + """app.js must define a closeFlyoutMenu function.""" + assert "function closeFlyoutMenu" in _JS, ( + "app.js must contain a closeFlyoutMenu function" + ) + + +def test_flyout_menu_uses_fixed_positioning() -> None: + """openFlyoutMenu must use position:fixed and getBoundingClientRect for positioning.""" + fn_body = _JS.split("function openFlyoutMenu")[1].split("\nfunction ")[0] + assert "getBoundingClientRect" in fn_body, ( + "openFlyoutMenu must use getBoundingClientRect to calculate position" + ) + + +def test_flyout_delegated_on_tile_container() -> None: + """A delegated click listener must handle .tile-options-btn clicks.""" + assert "tile-options-btn" in _JS.split("bindStaticEventListeners")[1], ( + "bindStaticEventListeners must handle .tile-options-btn clicks via delegation" + ) + + +def test_old_tile_delete_handler_removed() -> None: + """The old delegated .tile-delete click handler must be removed.""" + bind_body = _JS.split("function bindStaticEventListeners")[1].split("\nfunction ")[0] + assert "tile-delete" not in bind_body, ( + "The old .tile-delete delegated handler must be removed from bindStaticEventListeners" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_open_flyout_menu_function_exists -v +``` +Expected: FAIL — function does not exist + +**Step 3: Apply the changes to `muxplex/frontend/app.js`** + +Change 1 — Add a module-level state variable in the "App state" section (after `_previewSessionName` on line 133): +```javascript +// Flyout menu state +let _flyoutMenuEl = null; +let _flyoutSubmenuEl = null; +let _flyoutSessionKey = null; +let _flyoutSessionName = null; +let _flyoutRemoteId = null; +``` + +Change 2 — Add the flyout functions. Insert after the `showPreview` / `hidePreview` section (after line ~926): + +```javascript +// ── Tile Flyout Menu ───────────────────────────────────────────────────────── + +/** + * Open the flyout menu for a session tile's ⋮ button. + * Creates a floating menu appended to document.body, positioned relative to + * the trigger button via getBoundingClientRect. On mobile, renders as a + * bottom action sheet instead. + * @param {HTMLElement} triggerEl - The .tile-options-btn element that was clicked + */ +function openFlyoutMenu(triggerEl) { + closeFlyoutMenu(); + + // Read session info from the tile + var tile = triggerEl.closest('[data-session-key]'); + if (!tile) return; + _flyoutSessionKey = tile.dataset.sessionKey || ''; + _flyoutSessionName = tile.dataset.session || ''; + _flyoutRemoteId = tile.dataset.remoteId || ''; + + if (isMobile()) { + _openFlyoutSheet(); + return; + } + + // Build menu items based on active view type + var menuHtml = _buildFlyoutMenuItems(); + + var menu = document.createElement('div'); + menu.className = 'flyout-menu'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', 'Session options'); + menu.innerHTML = menuHtml; + document.body.appendChild(menu); + _flyoutMenuEl = menu; + + // Position relative to trigger + var rect = triggerEl.getBoundingClientRect(); + var menuWidth = menu.offsetWidth; + var menuHeight = menu.offsetHeight; + + // Default: below and to the left of the trigger + var top = rect.bottom + 4; + var left = rect.right - menuWidth; + + // Keep within viewport + if (left < 8) left = 8; + if (top + menuHeight > window.innerHeight - 8) { + top = rect.top - menuHeight - 4; + } + if (top < 8) top = 8; + + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; + + // Delegated click handler on the flyout + menu.addEventListener('click', _handleFlyoutClick); + + // Close on click-outside (next tick to avoid the opening click) + setTimeout(function() { + document.addEventListener('click', _flyoutOutsideClickHandler, true); + }, 0); +} + +/** + * Close the flyout menu and any open submenu. + */ +function closeFlyoutMenu() { + if (_flyoutSubmenuEl) { + _flyoutSubmenuEl.remove(); + _flyoutSubmenuEl = null; + } + if (_flyoutMenuEl) { + _flyoutMenuEl.removeEventListener('click', _handleFlyoutClick); + _flyoutMenuEl.remove(); + _flyoutMenuEl = null; + } + // Remove mobile sheet if open + var sheet = document.querySelector('.flyout-sheet'); + if (sheet) sheet.remove(); + + document.removeEventListener('click', _flyoutOutsideClickHandler, true); + _flyoutSessionKey = null; + _flyoutSessionName = null; + _flyoutRemoteId = null; +} + +/** + * Click-outside handler for the flyout menu. + * @param {MouseEvent} e + */ +function _flyoutOutsideClickHandler(e) { + if (_flyoutMenuEl && !_flyoutMenuEl.contains(e.target) && + (!_flyoutSubmenuEl || !_flyoutSubmenuEl.contains(e.target))) { + closeFlyoutMenu(); + } +} +``` + +Change 3 — Remove the old `.tile-delete` delegated handler. In `bindStaticEventListeners()` (lines 2185–2194), **replace** the entire block: + +```javascript +// Before (lines 2184-2194): + // Delegated kill-session handler (tiles + sidebar items are re-rendered each poll) + document.addEventListener('click', function(e) { + var deleteBtn = e.target.closest && e.target.closest('.tile-delete, .sidebar-delete'); + if (!deleteBtn) return; + e.stopPropagation(); + var name = deleteBtn.dataset.session; + // Walk up to the tile/sidebar-item to get remoteId for federation routing + var container = deleteBtn.closest('[data-remote-id]'); + var remoteId = container ? container.dataset.remoteId : ''; + if (name) killSession(name, remoteId); + }); + +// After: + // Delegated ⋮ options button handler (tiles are re-rendered each poll) + document.addEventListener('click', function(e) { + var optionsBtn = e.target.closest && e.target.closest('.tile-options-btn'); + if (!optionsBtn) return; + e.stopPropagation(); + openFlyoutMenu(optionsBtn); + }); +``` + +Change 4 — Export the new functions in `window.MuxplexApp` (after `killSession,`): +```javascript + // Flyout menu + openFlyoutMenu, + closeFlyoutMenu, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "flyout_menu or tile_delete_handler" -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: add flyout menu open/close/position base, replace old tile-delete handler" +``` + +--- + +## Task 4: Context-Dependent Menu Items — Data Map + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +Define `FLYOUT_MENU_MAP` — a data map keyed by view type (`all`, `user`, `hidden`) that returns the menu item configuration for each context. Implement `_buildFlyoutMenuItems()` which reads `_activeView` and the map to generate HTML. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_flyout_menu_map_exists() -> None: + """app.js must define a FLYOUT_MENU_MAP data structure.""" + assert "FLYOUT_MENU_MAP" in _JS, ( + "app.js must contain a FLYOUT_MENU_MAP data structure" + ) + + +def test_flyout_menu_map_has_three_view_types() -> None: + """FLYOUT_MENU_MAP must have keys for 'all', 'user', and 'hidden'.""" + # The map should reference all three view types + map_section = _JS.split("FLYOUT_MENU_MAP")[1].split("};")[0] + assert "'all'" in map_section or '"all"' in map_section, ( + "FLYOUT_MENU_MAP must include an 'all' key" + ) + assert "'user'" in map_section or '"user"' in map_section, ( + "FLYOUT_MENU_MAP must include a 'user' key" + ) + assert "'hidden'" in map_section or '"hidden"' in map_section, ( + "FLYOUT_MENU_MAP must include a 'hidden' key" + ) + + +def test_build_flyout_menu_items_function_exists() -> None: + """app.js must define a _buildFlyoutMenuItems function.""" + assert "function _buildFlyoutMenuItems" in _JS, ( + "app.js must contain a _buildFlyoutMenuItems function" + ) + + +def test_build_flyout_uses_menu_map() -> None: + """_buildFlyoutMenuItems must reference FLYOUT_MENU_MAP.""" + fn_body = _JS.split("function _buildFlyoutMenuItems")[1].split("\nfunction ")[0] + assert "FLYOUT_MENU_MAP" in fn_body, ( + "_buildFlyoutMenuItems must reference FLYOUT_MENU_MAP (data-driven, not if/else)" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_flyout_menu_map_exists -v +``` +Expected: FAIL — `FLYOUT_MENU_MAP` not in JS + +**Step 3: Add the data map and builder to `muxplex/frontend/app.js`** + +Insert after the flyout state variables (after `_flyoutRemoteId`): + +```javascript +/** + * Data map of menu item definitions keyed by view type. + * Each entry is an array of item config objects with: + * { label, action, className?, separator? } + * The 'user' view type gets the active view name injected at render time. + */ +var FLYOUT_MENU_MAP = { + 'all': [ + { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, + { label: 'Hide', action: 'hide' }, + { separator: true }, + { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, + ], + 'user': [ + { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, + { label: 'Remove from {viewName}', action: 'remove-from-view' }, + { label: 'Hide', action: 'hide' }, + { separator: true }, + { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, + ], + 'hidden': [ + { label: 'Unhide', action: 'unhide' }, + { label: 'Unhide & Add to View\u2026', action: 'unhide-add-to-view', className: 'flyout-menu__item--has-submenu' }, + { separator: true }, + { label: 'Kill Session', action: 'kill', className: 'flyout-menu__item--danger' }, + ], +}; + +/** + * Build the flyout menu HTML string based on the active view type. + * Uses FLYOUT_MENU_MAP to generate items — no if/else chains. + * @returns {string} HTML for the menu items + */ +function _buildFlyoutMenuItems() { + // Determine view type: 'all', 'hidden', or 'user' + var viewType = _activeView; + if (viewType !== 'all' && viewType !== 'hidden') { + viewType = 'user'; + } + + var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all']; + var html = ''; + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + if (item.separator) { + html += ''; + continue; + } + + var label = item.label; + // Inject view name for "Remove from {viewName}" + if (label.indexOf('{viewName}') !== -1) { + var displayName = _activeView; + var fullName = _activeView; + if (displayName.length > 20) { + displayName = displayName.substring(0, 20) + '\u2026'; + } + label = label.replace('{viewName}', escapeHtml(displayName)); + } + + var cls = 'flyout-menu__item'; + if (item.className) cls += ' ' + item.className; + + var titleAttr = ''; + if (item.action === 'remove-from-view' && _activeView && _activeView.length > 20) { + titleAttr = ' title="Remove from ' + escapeHtml(_activeView) + '"'; + } + + html += ''; + } + + return html; +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "flyout_menu_map or build_flyout" -v +``` +Expected: All 4 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 FLYOUT_MENU_MAP data map and _buildFlyoutMenuItems builder" +``` + +--- + +## Task 5: Flyout Click Handler — Dispatch Actions + "Add to View" Submenu + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +Implement `_handleFlyoutClick(e)` that dispatches to the correct action based on `data-action`. Implement `_openFlyoutSubmenu(triggerItem)` for the "Add to View" submenu with checkmark toggles. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_handle_flyout_click_function_exists() -> None: + """app.js must define a _handleFlyoutClick function.""" + assert "function _handleFlyoutClick" in _JS, ( + "app.js must contain a _handleFlyoutClick function" + ) + + +def test_handle_flyout_click_dispatches_actions() -> None: + """_handleFlyoutClick must check data-action for dispatching.""" + fn_body = _JS.split("function _handleFlyoutClick")[1].split("\nfunction ")[0] + assert "data-action" in fn_body or "dataset.action" in fn_body, ( + "_handleFlyoutClick must read data-action from the clicked element" + ) + + +def test_open_flyout_submenu_function_exists() -> None: + """app.js must define a _openFlyoutSubmenu function.""" + assert "function _openFlyoutSubmenu" in _JS, ( + "app.js must contain a _openFlyoutSubmenu function" + ) + + +def test_submenu_toggles_view_membership() -> None: + """_openFlyoutSubmenu must PATCH /api/settings to toggle view membership.""" + fn_body = _JS.split("function _openFlyoutSubmenu")[1].split("\nfunction ")[0] + assert "views" in fn_body and ("PATCH" in fn_body or "api(" in fn_body), ( + "_openFlyoutSubmenu must PATCH /api/settings to add/remove session from view" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_handle_flyout_click_function_exists -v +``` +Expected: FAIL + +**Step 3: Add the functions to `muxplex/frontend/app.js`** + +Insert after `_buildFlyoutMenuItems()`: + +```javascript +/** + * Delegated click handler for the flyout menu. + * Dispatches based on data-action attribute. + * @param {MouseEvent} e + */ +function _handleFlyoutClick(e) { + var item = e.target.closest('[data-action]'); + if (!item) return; + + var action = item.dataset.action; + + switch (action) { + case 'add-to-view': + case 'unhide-add-to-view': + _openFlyoutSubmenu(item, action === 'unhide-add-to-view'); + break; + case 'remove-from-view': + _doRemoveFromView(); + break; + case 'hide': + _doHideSession(); + break; + case 'unhide': + _doUnhideSession(); + break; + case 'kill': + _doKillSessionInline(item); + break; + default: + break; + } +} + +/** + * Open the "Add to View" submenu next to a flyout menu item. + * Lists all user-created views with checkmarks for views the session is already in. + * Clicking a view toggles membership immediately via PATCH /api/settings. + * The flyout stays open after submenu actions. + * @param {HTMLElement} triggerItem - The menu item that triggered the submenu + * @param {boolean} unhideFirst - If true, also unhide the session (for "Unhide & Add to View") + */ +function _openFlyoutSubmenu(triggerItem, unhideFirst) { + // Close existing submenu + if (_flyoutSubmenuEl) { + _flyoutSubmenuEl.remove(); + _flyoutSubmenuEl = null; + } + + var views = (_serverSettings && _serverSettings.views) || []; + if (views.length === 0) { + showToast('No user views. Create one from the header dropdown.'); + return; + } + + var sessionKey = _flyoutSessionKey; + var html = ''; + for (var i = 0; i < views.length; i++) { + var v = views[i]; + var isIn = (v.sessions || []).indexOf(sessionKey) !== -1; + html += ''; + } + + var submenu = document.createElement('div'); + submenu.className = 'flyout-submenu'; + submenu.setAttribute('role', 'menu'); + submenu.innerHTML = html; + document.body.appendChild(submenu); + _flyoutSubmenuEl = submenu; + + // Position to the right of the trigger item (or left if no space) + if (_flyoutMenuEl) { + var menuRect = _flyoutMenuEl.getBoundingClientRect(); + var subWidth = submenu.offsetWidth; + var subHeight = submenu.offsetHeight; + var itemRect = triggerItem.getBoundingClientRect(); + + var left = menuRect.right + 4; + if (left + subWidth > window.innerWidth - 8) { + left = menuRect.left - subWidth - 4; + } + var top = itemRect.top; + if (top + subHeight > window.innerHeight - 8) { + top = window.innerHeight - subHeight - 8; + } + if (top < 8) top = 8; + + submenu.style.top = top + 'px'; + submenu.style.left = left + 'px'; + } + + // Click handler for submenu items + submenu.addEventListener('click', function(e) { + var btn = e.target.closest('[data-view-index]'); + if (!btn) return; + var idx = parseInt(btn.dataset.viewIndex, 10); + _toggleViewMembership(idx, sessionKey, unhideFirst); + }); +} + +/** + * Toggle a session's membership in a view. + * @param {number} viewIndex - Index in the views array + * @param {string} sessionKey - The session key to toggle + * @param {boolean} unhideFirst - If true, also remove from hidden_sessions + */ +function _toggleViewMembership(viewIndex, sessionKey, unhideFirst) { + var views = (_serverSettings && _serverSettings.views) || []; + var updatedViews = JSON.parse(JSON.stringify(views)); + var view = updatedViews[viewIndex]; + if (!view) return; + + var sessions = view.sessions || []; + var idx = sessions.indexOf(sessionKey); + if (idx !== -1) { + // Remove from view + sessions.splice(idx, 1); + } else { + // Add to view + sessions.push(sessionKey); + } + view.sessions = sessions; + + var patch = { views: updatedViews }; + + // If unhiding, also update hidden_sessions + if (unhideFirst) { + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hiddenIdx = hidden.indexOf(sessionKey); + if (hiddenIdx !== -1) { + var updatedHidden = hidden.slice(); + updatedHidden.splice(hiddenIdx, 1); + patch.hidden_sessions = updatedHidden; + } + } + + api('PATCH', '/api/settings', patch) + .then(function() { + if (_serverSettings) { + _serverSettings.views = updatedViews; + if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; + } + // Re-render the submenu to update checkmarks + if (_flyoutSubmenuEl) { + var checkItems = _flyoutSubmenuEl.querySelectorAll('[data-view-index]'); + for (var i = 0; i < checkItems.length; i++) { + var vi = parseInt(checkItems[i].dataset.viewIndex, 10); + var checkEl = checkItems[i].querySelector('.flyout-submenu__check'); + if (checkEl && updatedViews[vi]) { + checkEl.textContent = (updatedViews[vi].sessions || []).indexOf(sessionKey) !== -1 ? '\u2713' : ''; + } + } + } + // Refresh grid if needed (unhiding changes visible sessions) + if (unhideFirst) { + renderGrid(_currentSessions || []); + } + }) + .catch(function(err) { + showToast('Couldn\u2019t save \u2014 try again'); + console.warn('[_toggleViewMembership] PATCH failed:', err); + }); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "handle_flyout_click or open_flyout_submenu or submenu_toggles" -v +``` +Expected: All 4 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 flyout click handler, Add to View submenu with toggle" +``` + +--- + +## Task 6: Hide/Unhide/Remove Actions + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +Implement `_doHideSession()`, `_doUnhideSession()`, and `_doRemoveFromView()`. These are the non-kill flyout actions. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_do_hide_session_function_exists() -> None: + """app.js must define a _doHideSession function.""" + assert "function _doHideSession" in _JS, ( + "app.js must contain a _doHideSession function" + ) + + +def test_do_unhide_session_function_exists() -> None: + """app.js must define a _doUnhideSession function.""" + assert "function _doUnhideSession" in _JS, ( + "app.js must contain a _doUnhideSession function" + ) + + +def test_do_remove_from_view_function_exists() -> None: + """app.js must define a _doRemoveFromView function.""" + assert "function _doRemoveFromView" in _JS, ( + "app.js must contain a _doRemoveFromView function" + ) + + +def test_hide_session_removes_from_all_views() -> None: + """_doHideSession must update both hidden_sessions AND views (remove from all views).""" + fn_body = _JS.split("function _doHideSession")[1].split("\nfunction ")[0] + assert "hidden_sessions" in fn_body, ( + "_doHideSession must add session to hidden_sessions" + ) + assert "views" in fn_body, ( + "_doHideSession must remove session from all views (mutual exclusion)" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_do_hide_session_function_exists -v +``` +Expected: FAIL + +**Step 3: Add the functions to `muxplex/frontend/app.js`** + +Insert after `_toggleViewMembership()`: + +```javascript +/** + * Hide a session: add to hidden_sessions and remove from ALL views. + * Closes the flyout and re-renders the grid. + */ +function _doHideSession() { + var sessionKey = _flyoutSessionKey; + if (!sessionKey) return; + + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var views = (_serverSettings && _serverSettings.views) || []; + + // Add to hidden_sessions + var updatedHidden = hidden.slice(); + if (updatedHidden.indexOf(sessionKey) === -1) { + updatedHidden.push(sessionKey); + } + + // Remove from all views (mutual exclusion) + var updatedViews = JSON.parse(JSON.stringify(views)); + for (var i = 0; i < updatedViews.length; i++) { + var sessions = updatedViews[i].sessions || []; + var idx = sessions.indexOf(sessionKey); + if (idx !== -1) sessions.splice(idx, 1); + } + + closeFlyoutMenu(); + + api('PATCH', '/api/settings', { hidden_sessions: updatedHidden, views: updatedViews }) + .then(function() { + if (_serverSettings) { + _serverSettings.hidden_sessions = updatedHidden; + _serverSettings.views = updatedViews; + } + renderGrid(_currentSessions || []); + renderViewDropdown(); + }) + .catch(function(err) { + showToast('Couldn\u2019t save \u2014 try again'); + console.warn('[_doHideSession] PATCH failed:', err); + }); +} + +/** + * Unhide a session: remove from hidden_sessions. + * Closes the flyout and re-renders the grid. + */ +function _doUnhideSession() { + var sessionKey = _flyoutSessionKey; + if (!sessionKey) return; + + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var idx = hidden.indexOf(sessionKey); + if (idx === -1) { closeFlyoutMenu(); return; } + + var updatedHidden = hidden.slice(); + updatedHidden.splice(idx, 1); + + closeFlyoutMenu(); + + api('PATCH', '/api/settings', { hidden_sessions: updatedHidden }) + .then(function() { + if (_serverSettings) _serverSettings.hidden_sessions = updatedHidden; + renderGrid(_currentSessions || []); + renderViewDropdown(); + }) + .catch(function(err) { + showToast('Couldn\u2019t save \u2014 try again'); + console.warn('[_doUnhideSession] PATCH failed:', err); + }); +} + +/** + * Remove a session from the currently active user view. + * Closes the flyout and re-renders the grid. + */ +function _doRemoveFromView() { + var sessionKey = _flyoutSessionKey; + if (!sessionKey || _activeView === 'all' || _activeView === 'hidden') return; + + var views = (_serverSettings && _serverSettings.views) || []; + var updatedViews = JSON.parse(JSON.stringify(views)); + + // Find the active view and remove the session + for (var i = 0; i < updatedViews.length; i++) { + if (updatedViews[i].name === _activeView) { + var sessions = updatedViews[i].sessions || []; + var idx = sessions.indexOf(sessionKey); + if (idx !== -1) sessions.splice(idx, 1); + break; + } + } + + closeFlyoutMenu(); + + api('PATCH', '/api/settings', { views: updatedViews }) + .then(function() { + if (_serverSettings) _serverSettings.views = updatedViews; + renderGrid(_currentSessions || []); + }) + .catch(function(err) { + showToast('Couldn\u2019t save \u2014 try again'); + console.warn('[_doRemoveFromView] PATCH failed:', err); + }); +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "do_hide or do_unhide or do_remove_from_view" -v +``` +Expected: All 4 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 hide, unhide, and remove-from-view flyout actions" +``` + +--- + +## Task 7: Kill Session Inline Confirmation + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +Replace the old `confirm()` dialog with inline confirmation inside the flyout: clicking "Kill Session" replaces the item with "Kill? [Yes] [No]". On error shows "Failed" for 2 seconds. On success closes menu. Also update `killSession()` to no longer use `confirm()`. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_do_kill_session_inline_function_exists() -> None: + """app.js must define a _doKillSessionInline function.""" + assert "function _doKillSessionInline" in _JS, ( + "app.js must contain a _doKillSessionInline function" + ) + + +def test_kill_session_no_confirm_dialog() -> None: + """killSession must NOT use window.confirm() (replaced by inline confirmation).""" + fn_body = _JS.split("function killSession")[1].split("\nfunction ")[0] + assert "confirm(" not in fn_body, ( + "killSession must not use confirm() — replaced by inline flyout confirmation" + ) + + +def test_do_kill_inline_shows_confirmation_buttons() -> None: + """_doKillSessionInline must render Yes/No confirmation buttons.""" + fn_body = _JS.split("function _doKillSessionInline")[1].split("\nfunction ")[0] + assert "Yes" in fn_body and "No" in fn_body, ( + "_doKillSessionInline must show 'Kill? [Yes] [No]' inline" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_do_kill_session_inline_function_exists -v +``` +Expected: FAIL + +**Step 3: Apply the changes to `muxplex/frontend/app.js`** + +Change 1 — Add `_doKillSessionInline()` after `_doRemoveFromView()`: + +```javascript +/** + * Show inline kill confirmation inside the flyout menu. + * Replaces the "Kill Session" item with "Kill? [Yes] [No]". + * No timeout — stays until click-outside closes the menu. + * On error: "Failed" for 2 seconds then reverts. + * @param {HTMLElement} killItem - The "Kill Session" menu item element + */ +function _doKillSessionInline(killItem) { + var sessionName = _flyoutSessionName; + var remoteId = _flyoutRemoteId; + + // Replace the kill item with confirmation UI + var confirmHtml = + '
' + + 'Kill?' + + '' + + '' + + '
'; + + killItem.outerHTML = confirmHtml; + + // Re-attach handlers on the confirm/cancel buttons + if (!_flyoutMenuEl) return; + + var confirmBtn = _flyoutMenuEl.querySelector('[data-action="confirm-kill"]'); + var cancelBtn = _flyoutMenuEl.querySelector('[data-action="cancel-kill"]'); + + if (confirmBtn) { + confirmBtn.addEventListener('click', function(e) { + e.stopPropagation(); + _executeKill(sessionName, remoteId); + }); + } + + if (cancelBtn) { + cancelBtn.addEventListener('click', function(e) { + e.stopPropagation(); + closeFlyoutMenu(); + }); + } +} + +/** + * Execute the kill session API call from the flyout inline confirmation. + * On success: closes flyout, shows toast, refreshes sessions. + * On error: shows "Failed" for 2s in the confirm area, then reverts. + * @param {string} name + * @param {string} remoteId + */ +function _executeKill(name, remoteId) { + var endpoint = remoteId + ? '/api/federation/' + encodeURIComponent(remoteId) + '/sessions/' + encodeURIComponent(name) + : '/api/sessions/' + encodeURIComponent(name); + + api('DELETE', endpoint) + .then(function() { + closeFlyoutMenu(); + showToast('Session \'' + name + '\' killed'); + if (_viewingSession === name) { + closeSession(); + } + pollSessions(); + }) + .catch(function(err) { + // Show "Failed" for 2 seconds + var confirmDiv = _flyoutMenuEl && _flyoutMenuEl.querySelector('.flyout-menu__confirm'); + if (confirmDiv) { + confirmDiv.innerHTML = 'Failed'; + setTimeout(function() { + // Revert to original kill button if menu is still open + if (_flyoutMenuEl && confirmDiv.parentNode) { + confirmDiv.outerHTML = + ''; + } + }, 2000); + } + }); +} +``` + +Change 2 — Update `killSession()` (line 2160) to remove the `confirm()` call. It is now only used internally (sidebar kill still works through it): + +```javascript +// Before (line 2160-2161): +function killSession(name, remoteId) { + if (!confirm('Kill session "' + name + '"?')) return; + +// After: +function killSession(name, remoteId) { +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "kill_session_inline or kill_session_no_confirm" -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 inline kill confirmation in flyout, remove old confirm() dialog" +``` + +--- + +## Task 8: Add Sessions Panel — HTML + CSS + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/style.css` +- Test: `muxplex/tests/test_frontend_html.py` +- Test: `muxplex/tests/test_frontend_css.py` + +Add the Add Sessions panel overlay HTML and CSS. The panel shows all sessions NOT in the active user view, with immediate-commit checkboxes. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_html.py`: + +```python +def test_add_sessions_panel_exists() -> None: + """index.html must contain an add-sessions-panel element.""" + soup = _SOUP + assert soup.find(id="add-sessions-panel"), ( + "index.html must contain an element with id='add-sessions-panel'" + ) + + +def test_add_sessions_panel_has_role_dialog() -> None: + """add-sessions-panel must have role='dialog' and aria-modal='true'.""" + soup = _SOUP + panel = soup.find(id="add-sessions-panel") + assert panel, "Missing #add-sessions-panel" + assert panel.get("role") == "dialog", ( + "add-sessions-panel must have role='dialog'" + ) + assert panel.get("aria-modal") == "true", ( + "add-sessions-panel must have aria-modal='true'" + ) +``` + +Add to `muxplex/tests/test_frontend_css.py`: + +```python +def test_add_sessions_panel_styled() -> None: + """style.css must contain .add-sessions-panel styles.""" + css = read_css() + assert ".add-sessions-panel" in css, "style.css must style .add-sessions-panel" + + +def test_add_sessions_item_styled() -> None: + """style.css must contain .add-sessions-item styles.""" + css = read_css() + assert ".add-sessions-item" in css, "style.css must style .add-sessions-item" +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py::test_add_sessions_panel_exists muxplex/tests/test_frontend_css.py::test_add_sessions_panel_styled -v +``` +Expected: FAIL + +**Step 3: Add HTML to `muxplex/frontend/index.html`** + +Insert after the bottom-sheet div (after line 74, before the session-pill button): + +```html + + +``` + +**Step 4: Add CSS to `muxplex/frontend/style.css`** + +Append after the flyout sheet styles: + +```css +/* —— Add Sessions Panel ——————————————————————————————————————————————— */ + +.add-sessions-panel { + position: fixed; + inset: 0; + z-index: 250; + display: flex; + align-items: center; + justify-content: center; +} + +.add-sessions-panel__backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay); +} + +.add-sessions-panel__content { + position: relative; + width: 90%; + max-width: 440px; + max-height: 70vh; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.add-sessions-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); +} + +.add-sessions-panel__title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.add-sessions-panel__close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 18px; + cursor: pointer; + border-radius: 4px; +} + +.add-sessions-panel__close:hover { + background: var(--bg-surface); + color: var(--text); +} + +.add-sessions-panel__list { + overflow-y: auto; + padding: 8px 0; + flex: 1; +} + +.add-sessions-panel__empty { + padding: 24px 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.add-sessions-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + cursor: pointer; +} + +.add-sessions-item:hover { + background: var(--bg-surface); +} + +.add-sessions-item--hidden { + opacity: 0.5; +} + +.add-sessions-item__checkbox { + flex-shrink: 0; + accent-color: var(--accent); +} + +.add-sessions-item__name { + font-size: 13px; + color: var(--text); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.add-sessions-item__device { + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; +} + +.add-sessions-item__badge { + font-size: 10px; + color: var(--text-dim); + background: var(--bg); + border: 1px solid var(--border-subtle); + border-radius: 3px; + padding: 1px 5px; +} + +.add-sessions-item__disclosure { + width: 100%; + padding: 2px 16px 6px 42px; + font-size: 11px; + color: var(--text-dim); + font-style: italic; + display: none; +} + +/* Mobile: full-screen sheet for Add Sessions */ +@media (max-width: 599px) { + .add-sessions-panel__content { + width: 100%; + max-width: 100%; + max-height: 100%; + height: 100%; + border-radius: 0; + } +} +``` + +**Step 5: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py -k "add_sessions" muxplex/tests/test_frontend_css.py -k "add_sessions" -v +``` +Expected: All 4 new tests PASS + +**Step 6: Commit** + +```bash +cd muxplex && git add muxplex/frontend/index.html muxplex/frontend/style.css muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_css.py && git commit -m "feat: add Add Sessions panel HTML and CSS" +``` + +--- + +## Task 9: Add Sessions Panel — JS Logic + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +Implement `openAddSessionsPanel()`, `closeAddSessionsPanel()`, and `renderAddSessionsList()`. The panel shows sessions not in the active view with immediate-commit checkboxes. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_open_add_sessions_panel_function_exists() -> None: + """app.js must define an openAddSessionsPanel function.""" + assert "function openAddSessionsPanel" in _JS, ( + "app.js must contain an openAddSessionsPanel function" + ) + + +def test_close_add_sessions_panel_function_exists() -> None: + """app.js must define a closeAddSessionsPanel function.""" + assert "function closeAddSessionsPanel" in _JS, ( + "app.js must contain a closeAddSessionsPanel function" + ) + + +def test_render_add_sessions_list_function_exists() -> None: + """app.js must define a renderAddSessionsList function.""" + assert "function renderAddSessionsList" in _JS, ( + "app.js must contain a renderAddSessionsList function" + ) + + +def test_add_sessions_uses_immediate_commit() -> None: + """renderAddSessionsList must PATCH immediately on checkbox change (no batch Done).""" + fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] + assert "PATCH" in fn_body or "api(" in fn_body, ( + "renderAddSessionsList must fire PATCH on each checkbox change (immediate commit)" + ) + + +def test_add_sessions_shows_device_name() -> None: + """renderAddSessionsList must show device name next to each session.""" + fn_body = _JS.split("function renderAddSessionsList")[1].split("\nfunction ")[0] + assert "deviceName" in fn_body or "device" in fn_body, ( + "renderAddSessionsList must show device name for disambiguation" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_open_add_sessions_panel_function_exists -v +``` +Expected: FAIL + +**Step 3: Add the functions to `muxplex/frontend/app.js`** + +Insert after `_executeKill()`: + +```javascript +// ── Add Sessions Panel ─────────────────────────────────────────────────────── + +/** + * Open the Add Sessions panel for the active user view. + * Only available for user views (not "All" or "Hidden"). + */ +function openAddSessionsPanel() { + if (_activeView === 'all' || _activeView === 'hidden') return; + + var panel = $('add-sessions-panel'); + if (!panel) return; + + // Update title + var titleEl = $('add-sessions-title'); + if (titleEl) titleEl.textContent = 'Add Sessions to \u201c' + _activeView + '\u201d'; + + renderAddSessionsList(); + panel.classList.remove('hidden'); + + // Close on backdrop click + var backdrop = $('add-sessions-backdrop'); + if (backdrop) { + backdrop.onclick = closeAddSessionsPanel; + } + + // Close button + var closeBtn = $('add-sessions-close'); + if (closeBtn) { + closeBtn.onclick = closeAddSessionsPanel; + } +} + +/** + * Close the Add Sessions panel. + */ +function closeAddSessionsPanel() { + var panel = $('add-sessions-panel'); + if (panel) panel.classList.add('hidden'); +} + +/** + * Render the session list inside the Add Sessions panel. + * Shows all sessions NOT currently in the active view. + * Hidden sessions are shown dimmed with a "hidden" badge and disclosure text. + * Grouped by device, alphabetical within each group. + * Immediate-commit checkboxes — each change fires a PATCH immediately. + */ +function renderAddSessionsList() { + var listEl = $('add-sessions-list'); + var emptyEl = $('add-sessions-empty'); + if (!listEl) return; + + var views = (_serverSettings && _serverSettings.views) || []; + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + + // Find the active view's session list + var activeViewObj = null; + for (var i = 0; i < views.length; i++) { + if (views[i].name === _activeView) { + activeViewObj = views[i]; + break; + } + } + if (!activeViewObj) { listEl.innerHTML = ''; return; } + var viewSessions = activeViewObj.sessions || []; + + // Get all real sessions (not status entries), excluding those already in the view + var allSessions = (_currentSessions || []).filter(function(s) { + return !s.status; + }); + + var notInView = allSessions.filter(function(s) { + var key = s.sessionKey || s.name; + return viewSessions.indexOf(key) === -1; + }); + + if (notInView.length === 0) { + listEl.innerHTML = ''; + if (emptyEl) emptyEl.style.display = ''; + return; + } + if (emptyEl) emptyEl.style.display = 'none'; + + // Sort: group by deviceName, alphabetical within each group + notInView.sort(function(a, b) { + var da = (a.deviceName || '').toLowerCase(); + var db = (b.deviceName || '').toLowerCase(); + if (da !== db) return da < db ? -1 : 1; + var na = (a.name || '').toLowerCase(); + var nb = (b.name || '').toLowerCase(); + return na < nb ? -1 : na > nb ? 1 : 0; + }); + + var html = ''; + for (var j = 0; j < notInView.length; j++) { + var s = notInView[j]; + var key = s.sessionKey || s.name; + var isHidden = hidden.indexOf(key) !== -1 || hidden.indexOf(s.name) !== -1; + var escapedName = escapeHtml(s.name || ''); + var deviceName = escapeHtml(s.deviceName || ''); + + html += ''; + if (isHidden) { + html += '
This will make it visible again.
'; + } + } + + listEl.innerHTML = html; + + // Delegated change handler for immediate-commit checkboxes + listEl.onchange = function(e) { + var cb = e.target.closest('.add-sessions-item__checkbox'); + if (!cb) return; + var sessionKey = cb.dataset.sessionKey; + var isHiddenSession = cb.dataset.isHidden === '1'; + + if (cb.checked) { + // Add to view (and unhide if hidden) + _addSessionToActiveView(sessionKey, isHiddenSession, cb); + } else { + // Should not normally happen (unchecking means removing), but handle gracefully + cb.checked = false; + } + }; + + // Show/hide disclosure on hover for hidden items + listEl.onmouseover = function(e) { + var item = e.target.closest('.add-sessions-item--hidden'); + if (item) { + var disc = item.nextElementSibling; + if (disc && disc.classList.contains('add-sessions-item__disclosure')) { + disc.style.display = ''; + } + } + }; + listEl.onmouseout = function(e) { + var item = e.target.closest('.add-sessions-item--hidden'); + if (item) { + var disc = item.nextElementSibling; + if (disc && disc.classList.contains('add-sessions-item__disclosure')) { + disc.style.display = 'none'; + } + } + }; +} + +/** + * Add a session to the active user view via PATCH. + * If the session is hidden, also unhide it. + * On error: show toast, revert checkbox. + * @param {string} sessionKey + * @param {boolean} unhideFirst + * @param {HTMLInputElement} checkbox + */ +function _addSessionToActiveView(sessionKey, unhideFirst, checkbox) { + var views = (_serverSettings && _serverSettings.views) || []; + var updatedViews = JSON.parse(JSON.stringify(views)); + + // Find active view and add session + for (var i = 0; i < updatedViews.length; i++) { + if (updatedViews[i].name === _activeView) { + var sessions = updatedViews[i].sessions || []; + if (sessions.indexOf(sessionKey) === -1) { + sessions.push(sessionKey); + } + updatedViews[i].sessions = sessions; + break; + } + } + + var patch = { views: updatedViews }; + + if (unhideFirst) { + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var hiddenIdx = hidden.indexOf(sessionKey); + if (hiddenIdx !== -1) { + var updatedHidden = hidden.slice(); + updatedHidden.splice(hiddenIdx, 1); + patch.hidden_sessions = updatedHidden; + } + } + + api('PATCH', '/api/settings', patch) + .then(function() { + if (_serverSettings) { + _serverSettings.views = updatedViews; + if (patch.hidden_sessions) _serverSettings.hidden_sessions = patch.hidden_sessions; + } + // Re-render the list (session is now in the view, so it disappears from the list) + renderAddSessionsList(); + // Refresh the grid behind the panel + renderGrid(_currentSessions || []); + }) + .catch(function(err) { + showToast('Couldn\u2019t save \u2014 try again'); + if (checkbox) checkbox.checked = false; + console.warn('[_addSessionToActiveView] PATCH failed:', err); + }); +} +``` + +Change 2 — Export the new functions in `window.MuxplexApp`: +```javascript + // Add Sessions panel + openAddSessionsPanel, + closeAddSessionsPanel, + renderAddSessionsList, +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "add_sessions" -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: add Add Sessions panel JS logic with immediate-commit checkboxes" +``` + +--- + +## Task 10: Mobile Variants — Bottom Action Sheet + Full-Screen Panels + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Test: `muxplex/tests/test_frontend_js.py` + +On mobile (`isMobile()`), the `⋮` tap opens a bottom action sheet instead of a floating menu. "Add to View" on mobile opens a full-height picker sheet. The Add Sessions panel already handles mobile via the CSS `@media` rule from Task 8. + +**Step 1: Write the failing tests** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_open_flyout_sheet_function_exists() -> None: + """app.js must define a _openFlyoutSheet function for mobile.""" + assert "function _openFlyoutSheet" in _JS, ( + "app.js must contain a _openFlyoutSheet function for mobile bottom sheet" + ) + + +def test_open_flyout_menu_checks_mobile() -> None: + """openFlyoutMenu must check isMobile() to decide between flyout and sheet.""" + fn_body = _JS.split("function openFlyoutMenu")[1].split("\nfunction ")[0] + assert "isMobile" in fn_body, ( + "openFlyoutMenu must check isMobile() to branch between flyout and sheet" + ) +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_open_flyout_sheet_function_exists -v +``` +Expected: FAIL + +**Step 3: Add the mobile sheet function to `muxplex/frontend/app.js`** + +Insert after `closeFlyoutMenu()`: + +```javascript +/** + * Open a bottom action sheet for the flyout menu (mobile). + * Same actions as the desktop flyout, but renders as a full-width bottom sheet. + */ +function _openFlyoutSheet() { + var viewType = _activeView; + if (viewType !== 'all' && viewType !== 'hidden') viewType = 'user'; + + var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all']; + + var html = '
'; + html += '
'; + html += ''; + + for (var i = 0; i < items.length; i++) { + var item = items[i]; + if (item.separator) { + html += '
'; + continue; + } + + var label = item.label; + if (label.indexOf('{viewName}') !== -1) { + var displayName = _activeView; + if (displayName.length > 20) displayName = displayName.substring(0, 20) + '\u2026'; + label = label.replace('{viewName}', escapeHtml(displayName)); + } + + var cls = 'flyout-sheet__item'; + if (item.className && item.className.indexOf('danger') !== -1) cls += ' flyout-sheet__item--danger'; + + html += ''; + } + + html += '
'; + + var sheet = document.createElement('div'); + sheet.className = 'flyout-sheet'; + sheet.setAttribute('role', 'dialog'); + sheet.setAttribute('aria-modal', 'true'); + sheet.innerHTML = html; + document.body.appendChild(sheet); + + // Backdrop closes + var backdrop = sheet.querySelector('.flyout-sheet__backdrop'); + if (backdrop) { + backdrop.addEventListener('click', closeFlyoutMenu); + } + + // Delegated action handler + var panel = sheet.querySelector('.flyout-sheet__panel'); + if (panel) { + panel.addEventListener('click', function(e) { + var btn = e.target.closest('[data-action]'); + if (!btn) return; + + var action = btn.dataset.action; + if (action === 'add-to-view' || action === 'unhide-add-to-view') { + // On mobile, close sheet and open Add Sessions panel + closeFlyoutMenu(); + openAddSessionsPanel(); + } else if (action === 'kill') { + // Simple confirm on mobile (inline doesn't work well in sheets) + closeFlyoutMenu(); + killSession(_flyoutSessionName, _flyoutRemoteId); + } else { + // Dispatch directly + _handleFlyoutClick(e); + } + }); + } +} +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py -k "flyout_sheet or open_flyout_menu_checks_mobile" -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 mobile bottom action sheet for flyout menu" +``` + +--- + +## Task 11: Wire Up "Add Sessions" Entry Point in Grid + +**Files:** +- Modify: `muxplex/frontend/app.js` (inside `renderGrid()`) +- Modify: `muxplex/frontend/style.css` +- Test: `muxplex/tests/test_frontend_js.py` + +When in a user-created view, show an "Add Sessions" affordance tile at the end of the grid that opens the Add Sessions panel. This gives users a discoverable entry point beyond the flyout submenu. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_render_grid_has_add_sessions_affordance() -> None: + """renderGrid must include an 'Add Sessions' affordance when in a user view.""" + fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] + assert "add-sessions" in fn_body.lower() or "openAddSessionsPanel" in fn_body, ( + "renderGrid must render an 'Add Sessions' affordance for user views" + ) +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_has_add_sessions_affordance -v +``` +Expected: FAIL + +**Step 3: Apply the changes to `muxplex/frontend/app.js`** + +In `renderGrid()`, after the grid HTML is assembled (right before `grid.innerHTML = html;`), add the "Add Sessions" affordance tile if in a user view: + +```javascript + // Add Sessions affordance tile — shown in user views only + if (_activeView !== 'all' && _activeView !== 'hidden') { + var viewsArr = (_serverSettings && _serverSettings.views) || []; + var isUserView = false; + for (var vi = 0; vi < viewsArr.length; vi++) { + if (viewsArr[vi].name === _activeView) { isUserView = true; break; } + } + if (isUserView) { + html += ''; + } + } +``` + +Add CSS to `muxplex/frontend/style.css` (after the Add Sessions panel styles): + +```css +/* —— Add Sessions affordance tile ————————————————————————————————————— */ + +.add-sessions-tile { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 120px; + background: transparent; + border: 2px dashed var(--border-subtle); + border-radius: 8px; + color: var(--text-dim); + font-size: 13px; + cursor: pointer; + transition: border-color var(--t-fast), color var(--t-fast); +} + +.add-sessions-tile:hover { + border-color: var(--accent); + color: var(--accent); +} + +.add-sessions-tile__icon { + font-size: 24px; + line-height: 1; +} + +.add-sessions-tile__label { + font-size: 12px; +} +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_has_add_sessions_affordance -v +``` +Expected: PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/style.css muxplex/tests/test_frontend_js.py && git commit -m "feat: add 'Add Sessions' affordance tile in user views grid" +``` + +--- + +## Task 12: Session Death Detection — Close Flyout if Session Dies + +**Files:** +- Modify: `muxplex/frontend/app.js` (the poll/render cycle) +- Test: `muxplex/tests/test_frontend_js.py` + +If the session being targeted by the flyout dies (disappears from `_currentSessions` during a poll), close the flyout. This handles the edge case where a user has the kill confirmation showing and the session dies externally. + +**Step 1: Write the failing test** + +Add to `muxplex/tests/test_frontend_js.py`: + +```python +def test_render_grid_closes_stale_flyout() -> None: + """renderGrid must close the flyout if the targeted session no longer exists.""" + fn_body = _JS.split("function renderGrid")[1].split("\nfunction ")[0] + assert "_flyoutSessionKey" in fn_body or "closeFlyoutMenu" in fn_body, ( + "renderGrid must check if the flyout's target session still exists and close if not" + ) +``` + +**Step 2: Run test to verify it fails** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_closes_stale_flyout -v +``` +Expected: FAIL + +**Step 3: Apply the change to `muxplex/frontend/app.js`** + +In `renderGrid()`, at the beginning of the function (after the `var grid = $('session-grid');` check), add: + +```javascript + // Close flyout if the targeted session no longer exists + if (_flyoutSessionKey) { + var flyoutStillExists = (sessions || []).some(function(s) { + return (s.sessionKey || s.name) === _flyoutSessionKey; + }); + if (!flyoutStillExists) { + closeFlyoutMenu(); + } + } +``` + +**Step 4: Run test to verify it passes** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_js.py::test_render_grid_closes_stale_flyout -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: close flyout when targeted session disappears" +``` + +--- + +## 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 3 feature chain end-to-end** + +Check that the full chain works by listing what Phase 3 established: + +1. Flyout CSS — `.flyout-menu`, `.flyout-submenu`, `.flyout-sheet`, `.tile-options-btn` all styled ✓ +2. `⋮` button on session tiles — replaces old `.tile-delete` button in `buildTileHTML()` ✓ +3. Flyout base JS — `openFlyoutMenu()` / `closeFlyoutMenu()` with `position:fixed` + `getBoundingClientRect` positioning ✓ +4. Context-dependent menu items — `FLYOUT_MENU_MAP` data map with `all`/`user`/`hidden` keys, no if/else ✓ +5. "Add to View" submenu — `_openFlyoutSubmenu()` with checkmark toggle, immediate PATCH, flyout stays open ✓ +6. Hide/Unhide/Remove actions — `_doHideSession()` (removes from all views + adds to hidden), `_doUnhideSession()`, `_doRemoveFromView()` ✓ +7. Kill session inline confirmation — `_doKillSessionInline()` replaces item with "Kill? [Yes] [No]", "Failed" for 2s on error ✓ +8. Add Sessions panel — HTML overlay with immediate-commit checkboxes, dimmed hidden sessions with badge, device names, alphabetical grouped by device, empty state message ✓ +9. Add Sessions panel JS — `openAddSessionsPanel()` / `renderAddSessionsList()` with PATCH on each checkbox ✓ +10. Mobile variants — `_openFlyoutSheet()` bottom action sheet, Add Sessions panel is full-screen via CSS media query ✓ +11. "Add Sessions" affordance tile — dashed-border tile in grid for user views ✓ +12. Session death detection — flyout closes if target session disappears during poll ✓ + +**Step 4: Verify all three phases work together** + +Run the full test suite one more time to confirm no cross-phase regressions: + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v --timeout=180 +``` + +**Step 5: 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 3 integration fixes" +``` + +--- + +## Summary: Phase 3 delivers 12 implementation tasks + 1 verification task + +| Task | What | Files | +|---|---|---| +| 1 | Flyout CSS (menu, submenu, mobile sheet, trigger) | `style.css`, `tests/test_frontend_css.py` | +| 2 | ⋮ button on tiles, remove old `.tile-delete` | `app.js`, `tests/test_frontend_js.py` | +| 3 | Flyout base JS (open, position, close, delegation) | `app.js`, `tests/test_frontend_js.py` | +| 4 | `FLYOUT_MENU_MAP` data map + `_buildFlyoutMenuItems()` | `app.js`, `tests/test_frontend_js.py` | +| 5 | Click handler + "Add to View" submenu with toggle | `app.js`, `tests/test_frontend_js.py` | +| 6 | Hide, Unhide, Remove from View actions | `app.js`, `tests/test_frontend_js.py` | +| 7 | Kill session inline confirmation (replaces `confirm()`) | `app.js`, `tests/test_frontend_js.py` | +| 8 | Add Sessions panel HTML + CSS | `index.html`, `style.css`, `tests/test_frontend_html.py`, `tests/test_frontend_css.py` | +| 9 | Add Sessions panel JS logic | `app.js`, `tests/test_frontend_js.py` | +| 10 | Mobile bottom action sheet for flyout | `app.js`, `tests/test_frontend_js.py` | +| 11 | "Add Sessions" affordance tile in grid | `app.js`, `style.css`, `tests/test_frontend_js.py` | +| 12 | Session death detection (close stale flyout) | `app.js`, `tests/test_frontend_js.py` | +| 13 | Final integration — full test suite | verification only | + +**After Phase 3, the Views feature is complete.** Users can: +- Switch views via the header dropdown (Phase 2) +- Right-click `⋮` on any tile to add/remove from views, hide/unhide, or kill (Phase 3) +- Use the Add Sessions panel to bulk-add sessions to a view (Phase 3) +- All actions use immediate-commit PATCH with error recovery (Phase 3) +- Mobile gets bottom action sheets and full-screen panels (Phase 3) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 46a18b8..05a9b2d 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -145,7 +145,7 @@ let _flyoutRemoteId = null; * { label, action, className?, separator? } * The 'user' view type gets the active view name injected at render time. */ -var FLYOUT_MENU_MAP = { +const FLYOUT_MENU_MAP = { 'all': [ { label: 'Add to View\u2026', action: 'add-to-view', className: 'flyout-menu__item--has-submenu' }, { label: 'Hide', action: 'hide' }, @@ -595,7 +595,6 @@ function buildSidebarHTML(session, currentSession) { `` + `` + `` @@ -734,8 +733,6 @@ function renderSidebar(sessions, currentSession) { const name = item.dataset.session; const remoteId = item.dataset.remoteId || ''; on(item, 'click', (e) => { - // Don't navigate when clicking the delete button inside the item - if (e.target.closest && e.target.closest('.sidebar-delete')) return; if (name !== currentSession) openSession(name, { remoteId }); }); }); @@ -1464,8 +1461,8 @@ function renderGrid(sessions) { // Bind interaction handlers on each tile document.querySelectorAll('.session-tile').forEach(function(tile) { on(tile, 'click', (e) => { - // Don't navigate when clicking the delete button inside the tile - if (e.target.closest && e.target.closest('.tile-delete')) return; + // Don't navigate when clicking the options button inside the tile + if (e.target.closest && e.target.closest('.tile-options-btn')) return; // Don't open error/status tiles (unreachable, auth_failed) if (tile.classList.contains('source-tile--error') || !tile.dataset.session) return; openSession(tile.dataset.session, { remoteId: tile.dataset.remoteId || '' }); @@ -1658,7 +1655,7 @@ function _openFlyoutSheet() { var items = FLYOUT_MENU_MAP[viewType] || FLYOUT_MENU_MAP['all']; var html = '
'; - html += '
'; + html += '