Part A: DOMContentLoaded now async with await loadServerSettings() before
first render. Removes lazy loadServerSettings() from inside .then() block.
Part B: Fix 18 failing tests in test_app.mjs:
- Sidebar tests: replace _localStorageStore['muxplex.sidebarOpen'] with
app._setServerSettings({sidebarOpen: ...}); update toggleSidebar mocks
to include classList.contains(); assert _serverSettings.sidebarOpen
instead of localStorage values
- loadGridViewMode/saveGridViewMode tests: replace localStorage setup with
_setServerSettings({gridViewMode: ...}); assert _serverSettings results
- cycleViewMode test: replace loadDisplaySettings/saveDisplaySettings with
_setServerSettings/getDisplaySettings
- activityIndicator tests (7): replace _localStorageStore['muxplex.display']
JSON.stringify() with app._setServerSettings({activityIndicator: ...})
- killSession test: increase substring limit from 500 to 600 chars
Part C: Add _getServerSettings test helper to app.js exports so tests can
read back _serverSettings state after sidebar/display mutations.
- Remove SIDEBAR_KEY constant (was an undefined variable bug)
- Rewrite initSidebar() to read sidebarOpen from _serverSettings
- Rewrite toggleSidebar() to derive state from DOM class and persist
to server via patchServerSetting
- Rewrite bindSidebarClickAway() to persist collapsed state via
patchServerSetting instead of localStorage
- Add 7 tests verifying SIDEBAR_KEY removal and _serverSettings usage
Move all display/UX settings from browser localStorage to server-side
settings.json. Flat keys approach (Approach A) - adds 10 new keys to
DEFAULT_SETTINGS, requiring zero changes to existing load/save/patch
functions or API endpoints.
Key decisions:
- No federation settings sync (each server owns its own settings)
- Drop notificationPermission (browser API is source of truth)
- sidebarOpen defaults to None for auto-detect on first load
- No migration needed - users reset preferences
- Fix auto_open vs auto_open_created naming alignment
Verified compatible with muxplex v0.2.0 (1b5207b).
- Add DELETE /api/federation/{remote_id}/sessions/{session_name} endpoint
to proxy session deletion to remote instances with Bearer auth
(follows same pattern as existing create/connect/bell-clear proxies)
- Update killSession() to accept optional remoteId parameter and route
through federation delete proxy when present
- Update delegated click handler to extract data-remote-id from parent
tile/sidebar element and pass to killSession() for remote routing
- Now users can delete sessions on remote devices from their local client
Fixes: https://github.com/bkrabach/muxplex/issues (delete remote sessions)
Generated with Amplifier
Seven bug fixes across five files:
1. auth.py: Add .json to static extension allowlist so /manifest.json
bypasses auth. Previously, unauthenticated requests got redirected to
the login page HTML which the browser tried to parse as JSON.
2. sessions.py: Catch FileNotFoundError in enumerate_sessions() alongside
RuntimeError. If the session command binary is missing from PATH,
asyncio.create_subprocess_exec raises FileNotFoundError, which was
unhandled and broke the poll loop.
3. settings.py: Fix federation key erasure when remote instance URLs are
edited. The key-preservation logic only restored keys by exact URL
match. Editing a URL (e.g. http:// to https://) changed the lookup
key, permanently erasing the real federation key. Added position-based
fallback for same-slot URL edits.
4. main.py: Replace fire-and-forget subprocess.Popen in create_session
with asyncio.create_subprocess_shell that checks the return code and
returns proper HTTP 500 errors. Added shutil.which() pre-flight check.
Commands that exit non-zero but still create the session (e.g.
amplifier-workspace which tries to attach after create) are detected
by checking enumerate_sessions() and treated as success.
5. main.py: Redact sensitive keys in PATCH /api/settings response,
matching the existing GET endpoint behavior.
6. main.py: Include exception type and message in federation 503 error
responses (connect, bell-clear, create-session).
7. app.js: Fix auto_open vs auto_open_created key mismatch. The settings
toggle read/wrote 'auto_open' but the backend key is
'auto_open_created'. The PATCH was silently dropped and the toggle
was completely non-functional.
🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)
Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
Bump version 0.1.0 → 0.1.1. Add CHANGELOG.md covering all changes
since initial release. Update README clipboard section to reflect
native xterm.js paste handling.
The source-inspection test checks each line individually for an 'if'
guard. The multi-line formatting of the 'if remote_key else {}' guard
split it across 3 lines, making the test see an unguarded header.
Consolidated to match the single-line pattern of the other 4 endpoints.
After 9 clipboard commits that kept breaking each other, COE review
determined: ttyd uses ClipboardAddon, not custom handlers. Ctrl+Shift+V
on Linux and Cmd+V on macOS both trigger native browser paste events
that xterm.js catches via its hidden textarea handler. Zero custom
paste code needed. Deleted synthetic ClipboardEvent dispatch, readText
calls, and all paste-related comments. Kept: Ctrl+Shift+C copy,
auto-copy on selection, OSC 52, Ctrl+F search.
Ctrl+Shift+V does not trigger a native browser paste event (unlike Cmd+V
on macOS). Fix: read clipboard via navigator.clipboard.readText(), then
dispatch a synthetic ClipboardEvent on xterm.js's hidden textarea. This
triggers xterm.js's built-in handlePasteEvent which handles CR/LF
normalization, bracketed paste mode, and correct UTF-8 encoding — the
same code path as Cmd+V.
Also replaces the old regression test that asserted Ctrl+Shift+V was
NOT intercepted, replacing it with the correct test for the synthetic
paste approach.
xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8.
Multi-byte UTF-8 characters like box-drawing ─ (U+2500, bytes E2 94 80)
were rendered as â (Latin-1 interpretation of 0xE2). Fix: decode with
TextDecoder before _term.write(), matching ttyd's official client pattern.
Add module-level _decoder (alongside existing _encoder) and use
_decoder.decode(payload) in the type 0x30 message handler.
_term.paste() garbled Unicode box-drawing characters (â instead of ─│┌┐).
Fix: manually apply bracketed paste wrapping (ESC[200~...ESC[201~) for
multiline protection, then send via _encodePayload/TextEncoder for correct
UTF-8 encoding. Combines the multiline fix with the proven encoding path.
Raw WebSocket send bypassed xterm.js's paste pipeline, so multiline
text was sent without bracketed paste markers (ESC[200~...ESC[201~).
The shell treated each newline as Enter, executing lines separately.
Fix: _term.paste(text) goes through the proper pipeline — converts
CR/LF, wraps in bracketed paste markers, then fires onData → WebSocket.
The reconnect logic in connectWebSocket() always called local
/api/sessions/{name}/connect even for remote sessions, causing 404.
Fix: check remoteId (captured from outer scope) and route through
/api/federation/{remoteId}/connect/{name} for remote sessions.
Remote sessions failed to reconnect after page refresh because
restoreState() and renderSheetList() didn't pass remoteId to
openSession(). Fix:
- state.py: add active_remote_id field to empty_state()
- main.py: extend StatePatch model to accept active_session and
active_remote_id; update PATCH /api/state to selectively update
only fields explicitly provided (using model_fields_set)
- app.js restoreState(): read state.active_remote_id and pass it
as remoteId option so page-refresh reconnects remote sessions
- app.js openSession(): fire-and-forget PATCH /api/state to persist
active_session + active_remote_id after successful connect
- app.js closeSession(): fire-and-forget PATCH /api/state to clear
both fields so refresh doesn't try to reopen a closed session
- app.js renderSheetList(): add data-remote-id to sheet items and
pass remoteId in click handler (previously called openSession(name)
with no remoteId, routing remote sessions to local 404 endpoint)
Fixes: POST /api/sessions/paperclip-adapter/connect 404 when clicking
a remote session from the mobile bottom sheet or after page refresh.
websockets.connect() used default SSL verification, rejecting self-signed
certs on remote instances. Same issue as the httpx fix (2c21dfe) but for
the websockets library. Fix: pass ssl.SSLContext with CERT_NONE when the
remote URL is wss://.
Our custom handler sent clipboard text via WebSocket, but the browser's
native paste event also fired on xterm.js's hidden textarea, causing
xterm's built-in paste handler to send the same text again. Fix:
e.preventDefault() suppresses the browser paste event.
The source-inspection test correctly caught one remaining unguarded
Authorization header in the WS proxy additional_headers. Applied the
same 'if remote_key else {}' pattern as the other 4 federation endpoints.
Clicking an unreachable remote device tile passed empty session name
to openSession(), producing /api/federation/2/connect/ (no session name)
→ 405. Fix: bail early if name is empty or whitespace.
Also added guard in grid click handlers to skip error/status tiles
entirely, preventing clicks on unreachable or auth_failed tiles.
Empty remote_instances[].key produced 'Bearer ' (illegal header value)
causing httpx to reject the request entirely. Fix: only include the
Authorization header when the key is non-empty. Remotes without keys
still work if their auth allows it (e.g., localhost bypass).
Fixes all 5 sites: fetch_remote GET, connect POST, bell/clear POST,
create-session POST, and WebSocket proxy additional_headers.
httpx.AsyncClient used default SSL verification, rejecting self-signed
certs from muxplex setup-tls. Remote HTTPS instances (e.g., setup-tls
with self-signed fallback) were unreachable — all federation requests
failed with CERTIFICATE_VERIFY_FAILED. Fix: verify=False on the
federation client. Bearer token auth still protects authorization.
blur handler on the name input was closing the entire new-session UI
when focus moved to the device select. Fixed: check if activeElement
is the sibling select before running cleanup. Same guard added to the
select's blur handler (check if focus returned to input).
Also adds Escape keydown handler on the select to allow cancelling
without returning focus to the input first.
Applied to both showNewSessionInput() and showFabSessionInput().
11 tests calling main() failed in GitHub Actions because ttyd is not
available in standard Ubuntu packages. _check_dependencies() calls
sys.exit(1) when ttyd is missing. Fixed by adding a mock_check_deps
pytest fixture and applying it to all 11 affected tests.
Affected tests:
- test_main_calls_serve_by_default
- test_main_passes_custom_host_and_port
- test_main_default_host_is_localhost
- test_main_passes_auth_flag
- test_main_passes_session_ttl_flag
- test_main_passes_none_for_unset_flags
- test_main_passes_explicit_host_only
- test_main_serve_subcommand_accepts_flags
- test_main_passes_tls_cert_and_key_flags
- test_main_passes_none_for_unset_tls_flags
- test_serve_subcommand_accepts_tls_flags
- Add contents: read permission to publish.yml (required for actions/checkout)
when any permission is explicitly set, unlisted permissions default to none
- Add timeout-minutes: 15 to publish.yml for consistency with ci.yml
- Add docstrings to 3 pyproject metadata test functions for consistency
with the rest of the 2000+ line test file
- Add .github/workflows/ci.yml with correct placement in muxplex repo
- Matrix: Python 3.11, 3.12, 3.13 with fail-fast: false
- Uses uv for Python and dependency management with package caching
- Runs pytest via uv run pytest -v (config in pyproject.toml addopts)
- timeout-minutes: 15 to prevent runaway CI costs
- permissions: contents: read for minimal GITHUB_TOKEN scope
Co-authored-by: Amplifier <amplifier@example.com>
The 5 pre-existing tests used \(\w+\) (single-param regex) but Task 4
changed createNewSession(name) → createNewSession(name, remoteId). Updated
regex to \([\w,\s]+\) to match one or more comma-separated parameters.
- Update showFabSessionInput to call _createDeviceSelect() for optional
device <select> element
- Append select (if present) then input to overlay
- Enter handler reads remoteId from select.value, passes to createNewSession
- Add test: showFabSessionInput creates device select when multi_device_enabled with remotes
- Add _createDeviceSelect() helper function after _createSessionInput()
- Returns null when multi_device_enabled is false or remote_instances is empty
- Creates <select class='new-session-device-select'> with local + remote options
- Pre-selects based on _activeFilterDevice match
- Update showNewSessionInput() to call _createDeviceSelect() and insert select
- cleanup() removes both select and input; restores btn display
- Enter handler reads remoteId from select.value, calls createNewSession(name, remoteId)
- Export _createDeviceSelect for testing
- Add 3 tests covering select creation, integration, and argument passing
Adds federation_create_session endpoint following the same pattern as
federation_connect and federation_bell_clear. The endpoint:
- Looks up remote by integer index into remote_instances in settings
- Sends POST {remote_url}/api/sessions with Bearer auth header and
JSON body {name: ...}
- Returns remote's JSON response
- Raises 404 for invalid remote_id, 503 when unreachable, 502 on HTTP error
Adds 4 tests covering:
- Proxy behavior with correct URL, auth header, and JSON body forwarding
- 404 for out-of-range remote_id
- 503 when ConnectError raised
- 502 when remote returns HTTP error status
Update updatePillBell and updateSessionPill to build a viewingKey
that accounts for remote sessions (_viewingRemoteId prefix) and
compare using s.sessionKey || s.name instead of s.name alone.
This prevents false bell suppression when a local session shares a
name with a remote session — the two are now distinguished by their
fully-qualified key (remoteId:name vs plain name).