Commit Graph

500 Commits

Author SHA1 Message Date
Brian Krabach c3a059a7ee fix(ui): suppress empty device status tiles in all view modes (not just grouped)
v0.6.4 added `&& _gridViewMode !== 'grouped'` to the status:empty branch in
renderGrid, suppressing the "No sessions" tile only in grouped grid view.

User reported the alienware-r13 "No sessions" tile still appearing in flat view
(gridViewMode: "flat"), which is the muxplex default. The original request was
unambiguous: "don't need a block for those that don't have anything to show" —
no view-mode qualifier was implied.

Changes:
- Both renderGrid call sites (early-return branch for visible.length === 0, and
  the normal rendering branch) now simply omit the status:empty else-if entirely.
  The status:empty sentinel may still arrive from the server; the renderer silently
  ignores it in every view mode.
- Updated surrounding comments to reflect the new unconditional suppression.
- Updated/replaced tests that asserted the old (wrong) flat-mode behaviour:
  - Replaced 'renderGrid shows "No sessions" status tile for status=empty devices'
    with a v0.6.5 variant that asserts NO tile is emitted.
  - Removed 'v0.6.4: status:empty block IS still rendered in flat grid mode'
    (superseded; kept as a tombstone comment).
- Added three new tests under 'v0.6.5 empty tile suppressed in all view modes':
  1. Flat view: empty sentinel → no source-tile--empty, no 'No sessions' text.
  2. Grouped view: same (regression guard for the v0.6.4 suppression).
  3. Mixed input: real session renders, empty sentinel is discarded, both modes.

No other rendering paths that produce a per-device block for empty devices were
found: renderSidebar and renderGroupedGrid both operate on getVisibleSessions()
output which already excludes status-bearing sentinels.

Test count: 402 (v0.6.4) → 404 (v0.6.5).
2026-05-17 16:55:12 -07:00
Brian Krabach 54772ba12b chore: bump version to 0.6.4 2026-05-17 16:37:21 -07:00
Brian Krabach 13e5cb2484 fix(cli): probe known uv/pip locations off PATH and propagate caught install failures
On three fleet devices muxplex update failed silently because
shutil.which('uv') returned None even though uv was installed:
  - tower (Unraid/root):   /root/.local/bin/uv
  - macOS (user):          ~/.local/bin/uv
  - spark-1 (snap):        /snap/bin/uv

The muxplex process running under systemd / launchd inherits a stripped
PATH that omits ~/.local/bin and /snap/bin.  shutil.which gives up at
PATH exhaustion; _find_uv() doesn't.

Add _find_uv() and _find_pip() helpers that:
  1. Try shutil.which first (PATH fast path).
  2. If that returns None, probe a curated list of known install locations
     checking os.path.exists + os.access(X_OK) for each candidate.
  3. Return the first found path, or None.

Known locations covered:
  uv:  ~/.local/bin/uv, /opt/homebrew/bin/uv, /usr/local/bin/uv,
       /snap/bin/uv, /root/.local/bin/uv
  pip: ~/.local/bin/{pip,pip3}, /opt/homebrew/bin/pip3,
       /usr/local/bin/pip3, /root/.local/bin/{pip,pip3}

shutil.which() calls for systemctl/launchctl in service.py are not
changed — those tools are reliably on PATH when present.

Exit-code propagation (sys.exit(1) on _install_failed) was already
implemented in v0.6.2; this commit adds 9 tests confirming the complete
behaviour including the uv/pip path-probing and exit-code paths.

Updated test_upgrade_falls_back_to_pip_when_uv_absent to monkeypatch
_find_uv directly (avoids false positives on dev systems where uv is
installed at a known non-PATH location).
2026-05-17 16:36:47 -07:00
Brian Krabach c5921eba65 fix(ui): suppress empty device blocks in renderGrid grouped mode
Root cause: the v0.6.3 fix added a guard in renderGroupedGrid
(groupSessions.length === 0) that is unreachable — groups are built by
iterating the already-filtered session list, so every group that exists
always has ≥1 entry.  The actual empty-device block was coming from a
separate code path in renderGrid that unconditionally appends a
source-tile--empty status tile for every federation remote whose server
returns {status: 'empty'}.  In flat mode this is correct and intentional
("No sessions" badge); in grouped mode it produced a visible block
showing the device name, exactly what the user saw for alienware-r13.

The v0.6.3 tests only covered the hidden-sessions case (sessions exist
but are hidden); they didn't cover the zero-sessions case (remote device
online, zero tmux sessions → server emits status:empty sentinel).  Both
status-tile generation paths in renderGrid (the visible.length===0 early
return and the main append-at-end path) were appending the empty tile
regardless of gridViewMode.

Fix: skip status:empty tiles when _gridViewMode === 'grouped'.
auth_failed and unreachable tiles are still shown in all modes because
they represent actionable error states.

Adds three regression tests in test_app.mjs:
- status:empty NOT rendered in grouped mode (would have caught the bug)
- status:empty IS still rendered in flat mode (backward compat)
- auth_failed/unreachable still appear in grouped mode
2026-05-17 16:36:24 -07:00
Brian Krabach d364829677 chore: bump version to 0.6.3 2026-05-17 16:10:03 -07:00
Brian Krabach 43c559f812 feat(ui): suppress empty device blocks in grouped grid view
In grouped-by-device mode, devices whose sessions are all filtered out
of the current view (hidden, not in the active user view, or status-only
tiles) were still rendering a device-group header with an empty body —
visual clutter that made the dashboard feel broken.

Change: renderGroupedGrid() now skips a device entirely when its session
list is empty after partitioning the already-filtered visible session set.
The guard (devSessions.length === 0 → continue) is placed before the
<h3 class="device-group-header"> write so no HTML is emitted for the
empty device. The empty-state UI path (visible.length === 0 early-return
in renderGrid) is unaffected — it runs before renderGroupedGrid is ever
called, so the "all sessions hidden" case still shows the empty state.

Filtering source: renderGrid() already calls getVisibleSessions() →
filterVisible(_currentSessions, _serverSettings, _activeView) before
passing the ordered list to renderGroupedGrid(), so status tiles and
hidden sessions are never in the partition input.

Tests added (test_app.mjs — v0.6.3 section):
- grouped view skips device header when device has only hidden sessions
- grouped view still shows device header when ≥1 session is visible
- empty-state still appears when every device has zero visible sessions
2026-05-17 16:09:53 -07:00
Brian Krabach 5f2f8fb36b chore: bump version to 0.6.2 2026-05-17 14:46:06 -07:00
Brian Krabach 78950cbf02 fix(cli): propagate install failures, restart service on partial upgrade, prefer uv tool over pip
Bug 1 — upgrade returned exit 0 on partial failure (macOS pip ImportError case):
  When the install subprocess fails (non-zero exit), upgrade() now sets
  _install_failed=True and calls sys.exit(1) after the finally block instead
  of silently returning.  A clear error message is printed so the caller /
  script can detect the failure.
  Files: muxplex/cli.py upgrade() install dispatch blocks.

Bug 2 — macOS launchd agent left unloaded when install fails:
  (a) Added _have_launchctl() helper mirroring the _have_systemctl() guard
      introduced in v0.6.1.  Every launchctl subprocess call in upgrade() and
      doctor() is now guarded — if launchctl is absent a clear note is printed
      and the step is skipped.
  (b) Wrapped stop + install + start in try/finally so the service-start step
      ALWAYS executes regardless of install outcome (success or failure).
      Applied to both the launchctl path (macOS) and the systemctl path (Linux/
      WSL) — same structural issue existed in both, only the macOS variant was
      observed in the field.
  Files: muxplex/cli.py _have_launchctl(), upgrade() try/finally.

Bug 3 — upgrade chose system pip3 over uv even when install was uv-tool-managed:
  Added uv-tool-managed detection: resolves the muxplex script on PATH and
  checks whether the target lives under ~/.local/share/uv/tools/.  When
  detected, uses 'uv tool install --reinstall --force muxplex' (package name,
  not a git URL) so the correct uv tool-environment is upgraded.  Falls back to
  pip when uv is absent from PATH — unchanged.
  Files: muxplex/cli.py upgrade() uv-tool detection + install dispatch.

Refs: v0.6.1 added _have_systemctl() and the Linux no-systemctl guard;
this commit brings parity for macOS and closes the remaining failure modes
observed during the fleet update.
2026-05-17 14:45:44 -07:00
Brian Krabach 831292279f chore: bump version to 0.6.1 2026-05-17 12:26:34 -07:00
Brian Krabach d7d07ec07e fix(cli): tolerate systems without systemctl in upgrade/doctor flows
Bug: On systems without systemd (Unraid OS 7.2.4, BSD, macOS containers,
and other non-systemd Linux hosts), running `muxplex upgrade` or
`muxplex update` crashed immediately with:

  FileNotFoundError: [Errno 2] No such file or directory: 'systemctl'

The check ran unconditionally before any install step, so the upgrade
aborted without even attempting to fetch the new version. Introduced
in v0.6.0.

Fix: Add a module-level `_have_systemctl() -> bool` helper to cli.py
(and service.py) that gates every systemd-specific operation behind
`shutil.which("systemctl") is not None`.

Call sites guarded in cli.py (upgrade() function):
  1. systemctl --user is-active muxplex  (stop-before-upgrade check)
  2. systemctl --user stop muxplex       (pre-install service stop)
  3. Service file regeneration step      (service_install() call)
  4. systemctl --user is-enabled muxplex (post-install restart check)
  5. systemctl --user daemon-reload      (post-install daemon reload)
  6. systemctl --user start muxplex      (post-install service start)

Behaviour on no-systemd systems:
  - Skips the is-active check (treats as unmanaged; prints skip note).
  - Skips the stop step.
  - Still performs the uv/pip install.
  - Skips service-file regeneration (prints skip note).
  - Skips the daemon-reload / start steps.
  - Prints: '! systemd not detected — restart muxplex manually to pick
    up the new version' with the running PID if pgrep finds one.
  - Still runs `muxplex doctor` for verification (no systemd required).

doctor() change:
  - On non-darwin platforms with no systemctl, now prints:
    '! Service: systemd not available on this platform'
    instead of silently doing nothing or crashing.

service.py change:
  - Public API functions (service_install, service_uninstall,
    service_start, service_stop, service_restart, service_status,
    service_logs) now check _have_systemctl() on the Linux path.
  - When absent, print a clear, friendly error pointing the user to
    `muxplex serve` instead of letting subprocess raise FileNotFoundError.

Tests added (muxplex/tests/test_cli.py, +8 tests):
  - test_have_systemctl_helper_exists
  - test_have_systemctl_returns_bool
  - test_upgrade_no_systemctl_runs_to_completion (regression)
  - test_upgrade_no_systemctl_prints_skip_note
  - test_upgrade_no_systemctl_prints_manual_restart_note
  - test_upgrade_with_systemctl_runs_systemd_commands
  - test_doctor_no_systemctl_shows_graceful_message
  - test_doctor_no_systemctl_does_not_crash
2026-05-17 12:26:20 -07:00
Brian Krabach 794e329938 chore: bump version to 0.6.0
Hidden-state redesign: filter-at-read visibility helper, operation layer,
stale-key pruning, UI dim styling (Phases 0+1+2+4+5).
2026-05-17 11:38:53 -07:00
Brian Krabach c3a719ab5d chore(release): wire normalize_session_keys + repair stale JS tests
Wire normalize_session_keys import and call into _run_poll_cycle step 13b, running
before the prune step. Use local device id from muxplex.identity.load_device_id().
Best-effort: errors are logged, poll cycle continues.

Fix 10 previously-failing JS tests:
- 8 were stale: regex search windows too narrow, refactored implementations, removed
  UI elements (settings dialog: 4→5 tabs; sessions panel: moved hidden_sessions to Views)
- 2 were DOM drift from feature additions
Each fix has a code comment explaining the change.

Add 3 new end-to-end tests in test_views.py verifying normalize→prune pipeline.

Final test counts: pytest 1266 passing (1263 + 3 new), node --test 396 passing.
2026-05-17 11:38:35 -07:00
Brian Krabach a202d827d1 feat(ui): dim hidden sessions in Manage View with CSS badge (Phase 5)
Hidden sessions in the Manage View panel now render at 55% opacity with a "(hidden)" pseudo-element badge driven by CSS.

renderManageViewList uses the isHidden() Phase 1 helper for the conditional class — no inline hidden-checks remain in the rendering path.

4 new tests verify the class is applied/removed correctly across renders.
2026-05-17 10:26:26 -07:00
Brian Krabach fe0a5e10b2 feat(views): prune stale session keys with local-only bookkeeping (Phase 4)
New pruning.py sidecar module with load_pruning_state / save_pruning_state. The sidecar lives at ~/.config/muxplex/pruning.json and is NEVER in SYNCABLE_KEYS — bookkeeping is per-device, not federated.

prune_stale_keys(settings, live_keys, *, pruning_state, grace_seconds, now) drops keys from hidden_sessions and view.sessions after they have been missing past the configurable grace period (default 24h, syncable via stale_key_grace_hours).

Wired into _run_poll_cycle as step 14 with try/except so failures never abort the cycle.

The prune ACTION (settings change) syncs normally via existing LWW; the bookkeeping does not.

24 new tests pass; total muxplex/tests/ = 1263 passed.
2026-05-17 10:26:20 -07:00
Brian Krabach 03bf24e8d4 refactor(views): introduce operation layer with pure and user-intent ops (Phase 2)
Backend (muxplex/views.py):
- Added five pure data operations that mutate settings dict in place:
  add_membership, remove_membership, remove_from_all_views, hide, unhide.
  Each operation is isolated and affects only its named field.
- Added 16 new tests under 'Pure data operations (Phase 2)' in test_views.py.
  Isolation verified: each operation only modifies its target field.

Frontend (muxplex/frontend/app.js):
- Added five pure ops (_opAddMembership, _opRemoveMembership,
  _opRemoveFromAllViews, _opHide, _opUnhide) and _cloneOpState helper.
- Added four user-intent operations with intentional asymmetry:
  * hideSessionOp = hide + removeFromAllViews (federation-safe;
    matches current UX; see design doc for rationale)
  * unhideSessionOp = unhide (orthogonal; does NOT change view membership)
  * addSessionToViewOp = unhide + addMembership (auto-unhide on add;
    expressed as explicit composition)
  * removeSessionFromViewOp = removeMembership (orthogonal; does NOT hide)
- Refactored 7 call sites to use user-intent ops: _doHideSession,
  _doUnhideSession, _doRemoveFromView, flyout submenu toggle, 'New View'
  flow, mobile picker toggle, Manage View checkbox.
- All new operations exported in module.exports.

Frontend tests (muxplex/frontend/tests/test_app.mjs):
- Added 30 new tests under 'Phase 2 operation layer'.
- Cover pure-op isolation, idempotency, no-op behavior, user-intent op
  composition, and guarantee that _serverSettings is never mutated.

Verification:
- muxplex/tests/: 1239 passed (1223 prior + 16 new pure-op tests)
- test_app.mjs: 372 passed, 10 pre-existing failures (Phase 1 had 21;
  Phase 2 reduces to 10; none Phase-2-related; all test unrelated details
  of createNewSession, renderFilterBar, loadGridViewMode)

Intentional inline-PATCH constructions (NOT regressions):
- Creating empty views (~1263, 1347, 1595)
- Moving views up/down in Settings tab (~2520, 2577)
- _saveViewsAndRerender (full views array persistence)
- createNewSession auto-add-to-active-view (session-creation side effect)
These are view-management operations, not session-in-view management.

Builds on Phase 0+1 (commit 0f9623d).
No behavior change — purely structural refactor.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-17 10:10:05 -07:00
Brian Krabach 0f9623d2c2 refactor(views): introduce visibility helper and schema version (Phases 0+1)
## Phase 0 — Schema Version Field

- Added SCHEMA_VERSION = 2 constant and _schema_version field to DEFAULT_SETTINGS
  in muxplex/settings.py to support versioned federation.
- Added _schema_version to SYNCABLE_KEYS so peers see the version but
  apply_synced_settings never accepts an incoming version (one-way: send only).
- save_settings() always clamps _schema_version to current SCHEMA_VERSION.
- Added peer_supports_v2() helper for federation handshake.
- 6 new tests under "Schema version (Phase 0)" in test_settings.py.

## Phase 1 — Backend & Frontend Visibility Helpers

Backend (muxplex/views.py):
- Added is_hidden(key, settings), filter_visible(sessions, settings, view,
  *, include_hidden=False), visible_count(...), and normalize_session_keys()
  to provide read-time visibility filtering.
- Updated module docstring to describe v2 semantics (hidden is a property,
  not a placement; legacy enforce_mutual_exclusion retained as v1 backstop).
- 22 new tests covering the full filter matrix and normalization edge cases.

Frontend (muxplex/frontend/app.js):
- Added isHidden, filterVisible, visibleCount to app.js (pre-ES6 idioms).
- Replaced getVisibleSessions body with thin wrapper around filterVisible.
- Replaced 8 raw .length count sites in dropdown, settings, and Manage View
  to route through visibleCount.
- Settings panel shows "N sessions (M hidden)" when M > 0.
- Manage View "in this view" count uses includeHidden: true.
- 17 new tests in test_app.mjs covering the same matrix as backend.
- Updated 5 stale tests in test_frontend_js.py.

## Additional Updates

- Updated test_readme.py to exempt internal underscore-prefixed setting keys
  from README documentation requirement.
- Updated docs/plans/2026-05-17-hidden-state-redesign-design.md with COE
  corrections and design notes (federation truth, Phase 3 deferral, schema
  version semantics, local-only pruning state, federation tests).

All 1223 tests in muxplex/tests/ pass. All 17 new JS tests pass.
No raw .length count sites remain in counting code paths.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-17 09:33:46 -07:00
Brian Krabach 64915777be chore: bump version to 0.5.0 2026-05-06 12:37:34 -07:00
Brian Krabach 33ea016f7e feat(tls): add --method ca for persistent local CA-signed leaves
Adds a new TLS certificate method ('ca') that generates a persistent local
Certificate Authority and signs leaf certificates against it. This allows
users to install the CA once on client devices and have browser-trusted
HTTPS for plain LAN names (my-host, 192.168.1.5, my-host.local) without
requiring external services like Tailscale.

Key improvements over existing --method selfsigned:
- Persistent CA: the root cert is stored separately and never rotates,
  so leaf certificate renewal doesn't require re-trusting on clients
- Auto-detected SANs: includes LAN IP, tailnet name (if applicable),
  hostname, and localhost variants
- Per-platform install guide: comprehensive docs/TRUSTING_THE_LOCAL_CA.md
  with Windows PowerShell, macOS/Linux, iOS, and Android install steps

Solves PWA installation issues on Windows machines with corporate IT
policy blocking Tailscale: PWAs now persist in standalone mode when the
local CA is trusted in the Windows user cert store.

Changes:
- muxplex/tls.py: added _default_lan_ip(), _default_tailnet_name(),
  generate_local_ca(), generate_leaf_signed_by_ca()
- muxplex/cli.py: added 'ca' to --method choices, wired setup_tls()
  to generate CA + leaf with auto-detected SAN
- docs/TRUSTING_THE_LOCAL_CA.md: comprehensive per-platform install guide
- README.md: added --method ca documentation and cross-links
- CHANGELOG.md: documented v0.5.0 features

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-06 12:37:34 -07:00
Brian Krabach abe6a97241 fix: use ResizeObserver for terminal refit after sidebar toggle
The previous transitionend approach (v0.4.5) had a { once: true } bug:
the sidebar transitions two CSS properties (width and min-width), and if
min-width fired first, the listener self-destructed before the width event
arrived — so _refitTerminal() was never called.

Replace the fragile event-based approach with a ResizeObserver on
#terminal-container. This automatically calls _fitAddon.fit() (debounced
50ms) whenever the container dimensions change, handling sidebar toggle,
browser resize, and any future layout change. Remove the now-unnecessary
window._refitTerminal global and the broken transitionend handler.

Bumps version to 0.4.6.
2026-05-02 07:45:11 -07:00
Brian Krabach 538d11165c fix: refit terminal after sidebar toggle animation completes
toggleSidebar() flipped the sidebar--collapsed CSS class but never told
xterm.js to recalculate its dimensions. The terminal canvas kept its old
column/row count until a new session was opened (which creates a fresh
terminal that measures correctly on first fit()).

Fix: expose window._refitTerminal() from terminal.js, then call it via
a transitionend listener in toggleSidebar() so the terminal refits once
the 250ms CSS width/transform transition finishes.

Bumps version to 0.4.5.
2026-05-02 07:08:18 -07:00
Brian Krabach 493eed5a60 test: update renderSidebar regex to match 3-parameter signature
The test regexes matched `renderSidebar(\w+,\s*\w+)` (exactly 2 params)
but the fix in 821c595 added a third `currentRemoteId` parameter.
Updated to use `\(.*?\)` which matches any parameter count.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
2026-05-02 06:57:20 -07:00
Brian Krabach 821c595abe fix: resolve session switching failure when same-named sessions exist on different devices
The sidebar click guard, active highlight, mobile bottom sheet, and kill-session
auto-close all compared sessions by name only, ignoring remoteId. This caused
same-named sessions on different devices to be treated as identical — clicking
one while viewing the other was silently discarded as a no-op.

Now all 6 comparison sites check both name AND remoteId, using the _viewingRemoteId
that was already being tracked but never used in guards.

Bumps version to 0.4.4.
2026-05-02 06:55:39 -07:00
Brian Krabach 3538ecf48d chore: bump version to 0.4.3 2026-04-16 12:12:00 -07:00
Brian Krabach 2aa483554f fix: update stale test assertions — version check, remoteId routing 2026-04-16 12:11:56 -07:00
Brian Krabach b9144d9ae5 chore: bump version to 0.4.2 2026-04-16 08:58:31 -07:00
Brian Krabach 769621a009 feat: add flyout menu to sidebar session items
- Add data-session-key attribute to buildSidebarHTML article element so
  openFlyoutMenu() can read session info via closest('[data-session-key]')
- Add tile-options-btn button (with aria-label and aria-haspopup) inside
  sidebar-item-header — reuses the same class as tile version so the
  existing delegated document click handler picks it up automatically
- Guard renderSidebar click handler against .tile-options-btn clicks so
  clicking ⋮ doesn't also trigger openSession()
- Add .sidebar-item-header .tile-options-btn CSS: compact 20×20px, subtle
  opacity-based visibility to fit the narrower sidebar layout
- Tests: buildSidebarHTML data-session-key, tile-options-btn presence/aria,
  renderSidebar click guard, CSS sidebar btn styling
2026-04-16 08:58:05 -07:00
Brian Krabach 600f34ebe0 chore: bump version to 0.4.1 2026-04-16 08:34:52 -07:00
Brian Krabach a4271bb89c fix: local sessions 404 — use remoteId (not deviceId) for routing attribute
buildTileHTML and buildSidebarHTML were using session.deviceId for the
data-remote-id attribute. Since deviceId is now non-null for ALL sessions
(including local), openSession() routed local sessions through federation
endpoints which returned 404. Fixed to use session.remoteId which is null
for local sessions, preserving the local vs federation routing distinction.
2026-04-16 08:34:46 -07:00
Brian Krabach d90f9cca30 chore: bump version to 0.4.0 2026-04-16 08:22:08 -07:00
Brian Krabach c910eca6a6 feat: manage view rename/delete, unified views submenu, stable checkbox list, dead code cleanup
Fix 1: Wire rename click handler in Manage View panel
- openManageViewPanel() now adds click handler on #manage-view-name
- Replaces h2 with inline manage-view-panel__name-input on click
- On Enter: validates (non-empty, max 30, not reserved, not duplicate)
  then PATCHes /api/settings, updates _activeView, re-renders panel
- On Escape / blur: reverts to text display
- Add CSS for .manage-view-panel__delete-btn and .manage-view-panel__confirm-text

Fix 2: Add delete button in Manage View panel header
- Adds trash button (id=manage-view-delete-btn) next to view name
- Clicking shows inline 'Delete this view? [Yes] [No]' confirmation
- On confirm: PATCH /api/settings to remove view, close panel,
  switch to 'all' view, show toast confirming deletion

Fix 3: Remove rename affordance from settings tab
- Remove cursor:pointer from .views-settings-name
- Remove .views-settings-name:hover hover/underline styles
- Remove .views-settings-rename-input CSS class (dead code)

Fix 4: Merge Add to View + Remove from View into unified Views submenu
- Remove 'remove-from-view' item from FLYOUT_MENU_MAP 'user' entry
- _openFlyoutSubmenu now shows ALL user views (no longer skips
  the current active view) — unified toggle-checkmark submenu
- Submenu already stays open during toggling (existing behavior)
- Update old test to match new correct behavior

Fix 5: Fix checkbox re-render layout thrash in Manage View panel
- renderManageViewList onChange .then() no longer calls renderManageViewList()
- Instead updates summaryEl.textContent in-place with current counts
- Prevents position jumps when toggling checkboxes in long lists

Fix 6: Clean up dead CSS
- Remove .add-sessions-tile + __icon + __label (old affordance tile)
- Remove .manage-view-panel__close (HTML uses __close-btn)
- Remove .manage-view-panel__title (HTML uses __name)
- Update CSS comment 'Add Sessions Panel' -> 'Manage View Panel'
- Keep .manage-view-panel__name-input (now used by Fix 1 rename input)
2026-04-16 08:11:48 -07:00
Brian Krabach 29b180a713 feat: UX overhaul — Manage View panel, dropdown refinements, label fixes, badge sizing 2026-04-16 06:56:59 -07:00
Brian Krabach 95371712f2 fix: UX refinements from live testing — sidebar new view, session counts, empty view flow, header add button, tile layout
Issue 1: Add '+ New View' to sidebar dropdown
- Add '+ New View' action button (data-action='new-view') to renderSidebarViewDropdown()
- Add showSidebarNewViewInput() function targeting #sidebar-view-dropdown-menu
- Wire sidebar dropdown click handler to call showSidebarNewViewInput()
- Also wire 'Manage Views' action in sidebar dropdown

Issue 2: Remove keyboard shortcut numbers, add session counts
- Remove <span class='view-dropdown__shortcut'> elements from both render functions
- Show count of non-hidden sessions next to 'All Sessions'
- Show each user view's session count (view.sessions.length) in parentheses
- Keep keyboard shortcuts functional (backtick, 1-9) — just remove visual display

Issue 3: Empty new view opens Add Sessions panel
- Call openAddSessionsPanel() after switchView() in showNewViewInput() Enter handler
- Same in showSidebarNewViewInput() — user immediately sees panel to populate new view

Issue 4: Add '+ New View' option to tile flyout submenu
- Add separator + '+ New View' button at bottom of _openFlyoutSubmenu() view list
- Click handler: close flyout, prompt for name, validate, create view with session, switchView
- Remove early exit when views.length === 0 (show New View option even with no views)

Issue 5: Fix openAddSessionsPanel entry point + move to header
- Remove broken add-sessions-tile with onclick='window.MuxplexApp.openAddSessionsPanel()'
- Add <button id='add-sessions-btn'> to header-actions in index.html (hidden by default)
- Add updateAddSessionsButton() to show/hide button based on active view
- Wire button click to openAddSessionsPanel() in bindStaticEventListeners()
- Call updateAddSessionsButton() from switchView() and DOMContentLoaded

Issue 6: Fix device badge overlapping flyout icon
- Move tile-options-btn inside tile-header as last flex item (was absolute sibling of article)
- Move device badge out of tile-meta, now a direct flex sibling between tile-name and tile-meta
- Remove tile-meta-sep separator (badge no longer in meta)
- Add gap: 4px to .tile-header CSS
- Remove position:absolute from .tile-options-btn CSS — flex-shrink:0 instead

Tests: Add 17 new static analysis tests in test_frontend_js.py for all 6 issues.
Update test_app.mjs tile tests to match new tile-header layout.
All 318 frontend tests + 224 backend tests pass.
2026-04-16 03:53:15 -07:00
Brian Krabach f9b96ec077 fix: prevent click-outside from closing dropdown during new-view input creation
Race condition: showNewViewInput() calls replaceChild() to swap the
'+ New View' button for an <input>. The click event then bubbles to the
document-level click-outside handler where e.target is the removed button,
no longer in the DOM. dropdown.contains(e.target) returns false, so
closeViewDropdown() fires immediately and the input disappears.

Fix: before closing, check whether the dropdown now contains a
.view-dropdown__new-input element. If it does, showNewViewInput() just
ran and we must not close the dropdown.

Test: added test_click_outside_view_dropdown_guards_against_new_view_input
to verify the guard is present in the handler.
2026-04-16 03:17:20 -07:00
Brian Krabach e2c27cfe99 fix: update test_app.mjs assertions for tile-options-btn and sidebar-delete removal 2026-04-15 22:37:37 -07:00
Brian Krabach 3200896e6c fix: flyout sheet title CSS, ARIA role correction, sidebar-delete cleanup
Fix A: Add .flyout-sheet__title CSS rule in style.css flyout-sheet section.
  - Kill confirm sheet renders a title div with class flyout-sheet__title
    but no CSS rule existed; title displayed as raw unstyled text.
  - Added padding/font/color/alignment rule after .flyout-sheet__handle.

Fix B: Change role='menuitem' to role='button' on Kill and Cancel buttons
  in _openMobileKillConfirm() (app.js).
  - Buttons inside a role='alertdialog' must use role='button' per ARIA spec;
    role='menuitem' is only valid inside role='menu'.

Fix C: Remove 3 orphaned .sidebar-delete CSS rule blocks from style.css.
  - .sidebar-delete, .sidebar-item:hover .sidebar-delete, .sidebar-delete:hover
    were dead code after the delete button was removed from buildSidebarHTML().

Tests added:
  - test_frontend_css.py: test_flyout_sheet_title_css_exists (Fix A)
  - test_frontend_js.py:  test_kill_confirm_buttons_use_role_button (Fix B)
  - test_frontend_css.py: test_no_sidebar_delete_css (Fix C)
2026-04-15 22:30:40 -07:00
Brian Krabach 8e53dbe1cf fix: address Phase 3 COE verification findings — tile click guard, disclosure visibility, mobile confirm, dead code cleanup 2026-04-15 21:57:38 -07:00
Brian Krabach 2d136f1ff7 feat: close flyout when targeted session disappears 2026-04-15 21:25:13 -07:00
Brian Krabach 95bfc36fd8 feat: add 'Add Sessions' affordance tile in user views grid 2026-04-15 21:24:26 -07:00
Brian Krabach 3fe6cf2de6 feat: add mobile bottom action sheet for flyout menu 2026-04-15 21:23:08 -07:00
Brian Krabach f9c7cc9c6e feat: add Add Sessions panel JS logic with immediate-commit checkboxes 2026-04-15 21:18:50 -07:00
Brian Krabach 1167c8df7a feat: add Add Sessions panel HTML and CSS 2026-04-15 21:13:25 -07:00
Brian Krabach e18f08a320 feat: add inline kill confirmation in flyout, remove old confirm() dialog 2026-04-15 21:07:08 -07:00
Brian Krabach 51770b961e feat: add hide, unhide, and remove-from-view flyout actions 2026-04-15 21:05:15 -07:00
Brian Krabach 1d5773ba22 feat: add flyout click handler, Add to View submenu with toggle 2026-04-15 21:03:47 -07:00
Brian Krabach e0700ef04d feat: add FLYOUT_MENU_MAP data map and _buildFlyoutMenuItems builder 2026-04-15 21:02:05 -07:00
Brian Krabach 5c836c6108 feat: add flyout menu open/close/position base, replace old tile-delete handler 2026-04-15 20:56:13 -07:00
Brian Krabach 66f62321f5 feat: replace tile-delete button with ⋮ options button on session tiles 2026-04-15 20:53:48 -07:00
Brian Krabach bbe4f2d3f7 feat: add flyout menu, submenu, and mobile bottom sheet CSS 2026-04-15 20:52:49 -07:00
Brian Krabach 30f61d159d fix: address Phase 2 COE verification findings — dropdown styling, ARIA, sidebar clipping, manage-views wiring, persistence
Fix 1: BEM class names — renderViewDropdown/renderSidebarViewDropdown now emit
view-dropdown__item, __separator, __shortcut, __action, __count matching CSS

Fix 2: role=menuitem — all buttons in both dropdown renderers now carry
role="menuitem" so handleGlobalKeydown arrow-key navigation works

Fix 3: Sidebar dropdown clipping — #sidebar-view-dropdown-menu changed to
position:fixed; toggleSidebarViewDropdown uses getBoundingClientRect() to
escape .session-sidebar { overflow:hidden } clipping

Fix 4: manage-views wiring — bindStaticEventListeners now calls
openSettings() + switchSettingsTab('views') instead of falling through to
closeViewDropdown only

Fix 5: active_view persistence — delete and rename paths in
renderViewsSettingsTab now PATCH /api/state when _activeView changes

Fix 6: Sidebar dropdown click-outside — second document click listener
closes sidebar-view-dropdown-menu on outside clicks

Fix 7: Case-insensitive reserved names — showNewViewInput and commitRename
use name.toLowerCase() for 'all'/'hidden' check

Fix 8: Rename input maxLength — views-settings-rename-input now has
maxLength=30 matching the creation input

Fix 9: Local device_id — added _localDeviceId variable, fetched from
/api/instance-info at startup; createNewSession uses _localDeviceId instead
of _serverSettings.device_id (which is not in /api/settings response)

Fix 10: Dead code — renderFilterBar body emptied; dead filter-bar click
handler removed from bindStaticEventListeners

Tests: 24 new tests added covering all 10 findings; 1070 total tests pass
2026-04-15 20:36:16 -07:00
Brian Krabach 3b8543ff44 feat: remove hidden sessions section from settings panel (task-12)
Remove the Hidden Sessions checkbox list from the Sessions settings tab.
The feature is superseded by the Hidden view + tile flyout (Phase 3),
which is accessible via the sidebar view dropdown.

Changes:
- muxplex/frontend/index.html: Remove #setting-hidden-sessions div block
- muxplex/frontend/app.js: Remove hidden sessions checkbox population
  block from openSettings() and delegated change handler from
  bindStaticEventListeners()
- muxplex/tests/test_frontend_html.py: Remove old
  test_html_sessions_panel_has_hidden_sessions_container (for removed
  element); add test_no_hidden_sessions_checkbox_list_in_settings
  asserting element no longer exists
- muxplex/tests/test_frontend_js.py: Remove
  test_bind_static_event_listeners_uses_delegated_handler_for_hidden_sessions
  and test_settings_hidden_sessions_uses_session_key (for removed JS code)
2026-04-15 19:19:09 -07:00