Introduce 4 new Lit components for dashboard, sidebar, previews, and mobile UX:
- sidebar-item: <sidebar-item> for expanded-view sidebar entries
- hover-preview: <hover-preview> full-window snapshot popover
- bottom-sheet-switcher: <bottom-sheet-switcher> mobile session switcher
- session-grid: <session-grid> wrapper owning sort/group/repeat loop with keyed rendering
Refactor app.js to leverage components:
- renderSidebar() now uses <sidebar-item> elements with event delegation
- renderGrid() simplified to property-setting on <session-grid>, which owns tile loop
- showPreview/hidePreview simplified to property-setting on <hover-preview>
- openBottomSheet/closeBottomSheet/renderSheetList simplified to property-setting on <bottom-sheet-switcher>
- Removed dead code (renderGroupedGrid, renderFilterBar bodies)
- Preview click and sheet events wired via component event listeners
Update index.html:
- Replace bottom sheet div with <bottom-sheet-switcher> custom element
- Replace session grid div with <session-grid> custom element
- Add <hover-preview> element
- Add new component imports
All 1306 tests pass.
Generated with Amplifier
This commit combines multiple improvements for mobile support, reliability, and performance:
Mobile & Accessibility Improvements:
- Fix touch scrolling on mobile by removing CSS overflow-y:hidden that blocked xterm.js native scroll
- Add beforeinput handler to prevent Android IME double-space-to-period duplication
- Implement mobile control character toolbar (Esc, Tab, Ctrl/Alt toggles, arrows, special chars)
- One-shot modifier toggles bring soft keyboard on demand without showing it for other keys
- Replace 25+ unicode/HTML entity icons with inline SVGs across app for better rendering
Security & Reliability:
- Fix shell injection vulnerability: session names with spaces/special chars now properly quoted
- Use shlex.quote() in create_session and delete_session endpoints
- URL-encode session names in bell hook to prevent malformed curl URLs
- Also hardens against command injection through crafted session names
Performance Optimizations:
- Eliminate 2.4-5.6 second session creation delay (was 3 compounding bottlenecks)
- Frontend: check for new session immediately (was waiting 2s for interval tick)
- Backend: eagerly refresh session cache after tmux creation (was waiting for next poll)
- Connection: poll for ttyd readiness instead of blind 0.8s sleep
- Typical new session now appears in UI in 200-400ms vs previous 4-5 seconds
Infrastructure:
- Improve ttyd port detection with multi-tool fallback (lsof → fuser → ss)
- Add Cloudflare tunnel setup documentation
Test Updates:
- Update frontend tests to match new setTimeout recursion pattern (replaces setInterval)
All 1306 tests pass.
Generated with Amplifier
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
On one MacBook the plist generated by 'muxplex service install' contained:
<key>ProgramArguments</key>
<array>
<string>/Users/brkrabac/.../python3 -m muxplex</string>
<string>serve</string>
</array>
launchd treats each <string> element as a literal, unsplit argv token. The
first element was a single string with embedded spaces, so launchd tried to
exec a binary literally named 'python3 -m muxplex' (including the spaces) —
which doesn't exist. Because KeepAlive=true, launchd respawned the failed
exec every few seconds, so 'pgrep' showed a PID and 'muxplex doctor' reported
'Service: launchd agent running' even though the daemon NEVER bound to port
8088. The user had no visible signal.
Root cause: _resolve_muxplex_bin() returned a fallback string of the form
"$sys.executable -m muxplex" (a single string with spaces), which was
placed verbatim into a single <string> tag.
Fix:
* Add _resolve_muxplex_bin_for_launchd() which returns a list[str] of tokens:
1. Prefer ~/.local/bin/muxplex (stable uv-tool console-script symlink,
survives 'uv tool reinstall' without changing path — Option A from spec).
2. Fall back to shutil.which('muxplex').
3. Last resort: [sys.executable, '-m', 'muxplex'] — correctly split.
* Update _LAUNCHD_PLIST_TEMPLATE to take a {program_arguments_xml} placeholder
instead of a single {muxplex_bin}.
* In _launchd_install(), build argv = bin_args + ['serve'] and render each
token as its own <string> element.
Now the generated plist reads:
<key>ProgramArguments</key>
<array>
<string>/home/user/.local/bin/muxplex</string>
<string>serve</string>
</array>
Users who have an existing malformed plist will pick up the fix the next time
they run 'muxplex service install' (or after the upgrade flow regenerates the
service file).
Test added (test_service.py, under 'v0.6.7 fixes'):
- test_launchd_plist_program_arguments_are_separate_strings: calls
_launchd_install(), parses the result with plistlib.loads, asserts
ProgramArguments is a list with >= 2 elements and that NO element
contains a space.
During the v0.6.5→v0.6.6 rollout on spark-1 (systemd Linux) the upgrade flow
printed 'Restarting systemd service...' and 'Service started' but the unit was
actually inactive (dead) for 14 minutes afterward. The PWA was dark and no
alert fired because the CLI reported success.
Root cause: we fired daemon-reload + start but never verified the result. If
the unit was left in a 'failed' state (e.g. stale unit-file mismatch after the
previous ExecStart was regenerated mid-upgrade), systemd silently ignored the
start.
Fix:
* Add _probe_service_port(port) — lightweight HTTP probe to
localhost:port/login that returns True on any HTTP response.
* Add _verify_service_started(timeout_s=10) — polls 'systemctl --user
is-active' once (synchronous path) or polls the port via HTTP (async
launchctl path) and returns True/False.
* In upgrade() finally block (systemd path): call daemon-reload BEFORE start
(already present but now documented), then call _verify_service_started().
If not active, do reset-failed + retry start once. If still not active,
print a clear error and set _service_restart_failed = True.
* Propagate _service_restart_failed as sys.exit(1) after the try/finally, so
callers and scripts can detect that the service is not running.
* Augment doctor() systemd check: probe is-active and downgrade to a warn '!'
marker when the unit file exists but the service is not active.
* Augment doctor() launchd check: probe the HTTP port after confirming the
agent is registered; report '! launchd agent registered but not serving on
port N' when the port is not responding (catches the silent-failure mode from
Fix 2 as well).
Tests added (test_cli.py, under 'v0.6.7 fixes'):
- _verify_service_started returns True when is-active exits 0
- _verify_service_started returns False when is-active exits 3 (inactive)
- upgrade() exits 1 when service fails to restart after install
- upgrade() calls daemon-reload before start (call-order assertion)
- doctor() reports 'registered but not serving' when launchd agent is up
but port is not bound
Browser-tester confirmed on spark-1 that the 7 <script src> tags and all
<link href> tags in the served HTML had no cache-busting suffix. Because
there is no service worker, the standard HTTP cache would keep serving stale
JS/CSS to browsers that had already loaded a previous release — the root
cause of yesterday's 'is the user seeing stale JS?' investigation.
Fix: index_page() now appends ?v=<muxplex_version> to every static-asset
URL (src= and href= attributes whose path starts with / and does not begin
with /api/) before returning the HTML response. The version is read once at
module load from importlib.metadata.version('muxplex') — the same source
used by the doctor command — so it is always in sync with the installed
package. Starlette's StaticFiles handler ignores query parameters when
serving files from disk, so the versioned URLs continue to resolve to the
same bytes; they just carry a new cache key on every release.
Affected assets (7 <script> + 6 <link> + 1 <img>):
/vendor/xterm.js, /vendor/xterm-addon-fit.js,
/vendor/xterm-addon-web-links.js, /vendor/xterm-addon-search.js,
/vendor/addon-image.js, /app.js, /terminal.js
/manifest.json, /favicon.ico, /favicon-32.png, /apple-touch-icon.png,
/vendor/xterm.css, /style.css
/wordmark-on-dark.svg
New tests in muxplex/tests/test_main.py:
- test_index_all_asset_urls_have_version_suffix — every <script src> and
<link href> in GET / carries ?v=<version>
- test_index_vendor_scripts_each_versioned — all 7 expected script URLs
are present with the version suffix
- test_versioned_asset_url_resolves_to_static_file — static handler
serves the file correctly when the query string is present
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).
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).
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
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
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.
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
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.
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.
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.
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>
## 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>
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>
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.
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.
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>
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.
- 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
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.
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)
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.
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.
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)