diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index 65dbc8f..838f966 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -1380,17 +1380,23 @@ function buildSources(settings) {
var sources = [
{ url: '', name: localName, type: 'local', status: 'authenticated', backoffMs: 2000 },
];
+ // Only add remote sources when multi-device is enabled.
+ // Smart default: treat as enabled if remote_instances is non-empty (backward compat).
var remotes = (settings && settings.remote_instances) || [];
- for (var i = 0; i < remotes.length; i++) {
- var r = remotes[i];
- if (r && r.url) {
- sources.push({
- url: r.url.replace(/\/+$/, ''),
- name: r.name || r.url,
- type: 'remote',
- status: 'authenticated',
- backoffMs: 2000,
- });
+ var multiDeviceEnabled = (settings && settings.multi_device_enabled) ||
+ (remotes.length > 0);
+ if (multiDeviceEnabled) {
+ for (var i = 0; i < remotes.length; i++) {
+ var r = remotes[i];
+ if (r && r.url) {
+ sources.push({
+ url: r.url.replace(/\/+$/, ''),
+ name: r.name || r.url,
+ type: 'remote',
+ status: 'authenticated',
+ backoffMs: 2000,
+ });
+ }
}
}
return sources;
@@ -1449,6 +1455,25 @@ function _saveRemoteInstances() {
});
}
+// ─── Multi-Device helper ──────────────────────────────────────────────────────────
+
+/**
+ * Enable or disable all Multi-Device tab fields (except the enable toggle itself).
+ * When disabled, the fields container gets opacity: 0.5 and inputs/selects/buttons
+ * are disabled so users cannot interact with them.
+ * @param {boolean} enabled
+ */
+function _updateMultiDeviceFieldsState(enabled) {
+ var fieldsContainer = $('multi-device-fields');
+ if (!fieldsContainer) return;
+ var controls = fieldsContainer.querySelectorAll('input, select, button');
+ controls.forEach(function(ctrl) {
+ ctrl.disabled = !enabled;
+ });
+ fieldsContainer.style.opacity = enabled ? '' : '0.5';
+}
+
+
// ─── Settings dialog ──────────────────────────────────────────────────────────
/**
@@ -1766,6 +1791,20 @@ function openSettings() {
deviceNameEl.value = (ss && ss.device_name) || '';
}
+ // Update document.title from device_name setting
+ if (ss && ss.device_name) {
+ document.title = ss.device_name;
+ }
+
+ // Multi-device enabled checkbox (with smart default: checked if remote_instances non-empty)
+ const multiDeviceEnabledEl = $('setting-multi-device-enabled');
+ if (multiDeviceEnabledEl) {
+ var remoteList = (ss && ss.remote_instances) || [];
+ multiDeviceEnabledEl.checked = !!(ss && ss.multi_device_enabled) ||
+ remoteList.length > 0;
+ _updateMultiDeviceFieldsState(multiDeviceEnabledEl.checked);
+ }
+
// Remote instances
const remoteInstancesEl = $('setting-remote-instances');
if (remoteInstancesEl) {
@@ -2298,11 +2337,21 @@ function bindStaticEventListeners() {
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
});
- // Sessions tab — device name with 500ms debounce
+ // Multi-Device tab — enable/disable toggle
+ on($('setting-multi-device-enabled'), 'change', function() {
+ var enabled = this.checked;
+ _updateMultiDeviceFieldsState(enabled);
+ patchServerSetting('multi_device_enabled', enabled).then(function() {
+ _sources = buildSources(_serverSettings);
+ });
+ });
+
+ // Multi-Device tab — device name with 500ms debounce; updates document.title immediately
var _deviceNameDebounceTimer;
on($('setting-device-name'), 'input', function() {
clearTimeout(_deviceNameDebounceTimer);
var val = this.value;
+ if (val) document.title = val;
_deviceNameDebounceTimer = setTimeout(function() {
patchServerSetting('device_name', val).then(function() {
_sources = buildSources(_serverSettings);
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index 8791bc2..41f04c8 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -90,6 +90,7 @@
+
@@ -123,21 +124,6 @@
-
-
-
-
-
-
-
-
@@ -166,15 +152,6 @@
-
-
-
-
-
-
-
-
-
@@ -203,6 +180,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css
index ce7b852..c3503a4 100644
--- a/muxplex/frontend/style.css
+++ b/muxplex/frontend/style.css
@@ -1653,3 +1653,8 @@ body {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
+
+/* Multi-Device tab — smooth enable/disable opacity transition */
+#multi-device-fields {
+ transition: opacity 0.2s ease;
+}
diff --git a/muxplex/settings.py b/muxplex/settings.py
index d5103f3..717d3c3 100644
--- a/muxplex/settings.py
+++ b/muxplex/settings.py
@@ -21,6 +21,7 @@ DEFAULT_SETTINGS: dict = {
"remote_instances": [],
"device_name": "",
"delete_session_template": "tmux kill-session -t {name}",
+ "multi_device_enabled": False,
}
diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py
index b5544a8..25911c7 100644
--- a/muxplex/tests/test_frontend_css.py
+++ b/muxplex/tests/test_frontend_css.py
@@ -1987,3 +1987,23 @@ def test_fit_view_pre_has_top_zero() -> None:
assert "top: 0" in block or "top:0" in block, (
".session-grid--fit .tile-body pre must have top: 0 to fill full tile body height"
)
+
+
+# ============================================================
+# Multi-Device tab CSS (settings UI reorganization)
+# ============================================================
+
+
+def test_css_multi_device_fields_transition() -> None:
+ """#multi-device-fields must have a CSS transition for smooth enable/disable animation."""
+ css = read_css()
+ assert "#multi-device-fields" in css, (
+ "Missing #multi-device-fields CSS rule — needed for smooth enable/disable opacity transition"
+ )
+ match = re.search(r"#multi-device-fields\s*\{([^}]*)\}", css, re.DOTALL)
+ assert match, "#multi-device-fields CSS rule not found"
+ body = match.group(1)
+ assert "transition" in body, (
+ "#multi-device-fields must have a transition property for smooth enable/disable animation"
+ )
+ assert "opacity" in body, "#multi-device-fields transition must include opacity"
diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py
index eb998ab..3377f47 100644
--- a/muxplex/tests/test_frontend_html.py
+++ b/muxplex/tests/test_frontend_html.py
@@ -434,7 +434,7 @@ def test_html_settings_dialog() -> None:
def test_html_settings_tabs() -> None:
- """settings-dialog must contain 4 tab buttons with correct data-tab values."""
+ """settings-dialog must contain 5 tab buttons with correct data-tab values."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
@@ -442,7 +442,7 @@ def test_html_settings_tabs() -> None:
assert tabs_container is not None, (
"Missing nav.settings-tabs inside #settings-dialog"
)
- expected_tabs = ["display", "sessions", "notifications", "new-session"]
+ expected_tabs = ["display", "sessions", "notifications", "new-session", "devices"]
for tab_value in expected_tabs:
tab = tabs_container.find("button", attrs={"data-tab": tab_value})
assert tab is not None, (
@@ -540,8 +540,8 @@ def test_html_settings_panels_use_data_tab() -> None:
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
panels = dialog.find_all(class_="settings-panel")
- assert len(panels) == 4, (
- f"Expected 4 .settings-panel elements, found: {len(panels)}"
+ assert len(panels) == 5, (
+ f"Expected 5 .settings-panel elements, found: {len(panels)}"
)
for panel in panels:
assert panel.get("data-tab") is not None, (
@@ -1070,44 +1070,44 @@ def test_html_settings_close_btn_exists() -> None:
def test_html_sessions_tab_device_name_input() -> None:
- """Sessions tab must contain a #setting-device-name text input for the device name."""
+ """Multi-Device tab must contain a #setting-device-name text input for the device name."""
soup = _SOUP
el = soup.find(id="setting-device-name")
assert el is not None, "Missing element with id='setting-device-name'"
- # Must be inside the sessions panel
- sessions_panel = soup.find("div", attrs={"data-tab": "sessions"})
- assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')"
- assert sessions_panel.find(id="setting-device-name") is not None, (
- "#setting-device-name must be inside the sessions settings panel"
+ # Must be inside the devices panel (not sessions)
+ devices_panel = soup.find("div", attrs={"data-tab": "devices"})
+ assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
+ assert devices_panel.find(id="setting-device-name") is not None, (
+ "#setting-device-name must be inside the devices (Multi-Device) settings panel"
)
def test_html_sessions_tab_remote_instances_container() -> None:
- """Sessions tab must contain a #setting-remote-instances container for remote instance rows."""
+ """Multi-Device tab must contain a #setting-remote-instances container for remote instance rows."""
soup = _SOUP
el = soup.find(id="setting-remote-instances")
assert el is not None, "Missing element with id='setting-remote-instances'"
- # Must be inside the sessions panel
- sessions_panel = soup.find("div", attrs={"data-tab": "sessions"})
- assert sessions_panel is not None, "Missing sessions panel (data-tab='sessions')"
- assert sessions_panel.find(id="setting-remote-instances") is not None, (
- "#setting-remote-instances must be inside the sessions settings panel"
+ # Must be inside the devices panel
+ devices_panel = soup.find("div", attrs={"data-tab": "devices"})
+ assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
+ assert devices_panel.find(id="setting-remote-instances") is not None, (
+ "#setting-remote-instances must be inside the devices (Multi-Device) settings panel"
)
def test_html_sessions_tab_add_remote_instance_btn() -> None:
- """Sessions tab must contain an #add-remote-instance-btn button to add remote instances."""
+ """Multi-Device tab must contain an #add-remote-instance-btn button to add remote instances."""
soup = _SOUP
el = soup.find(id="add-remote-instance-btn")
assert el is not None, "Missing element with id='add-remote-instance-btn'"
assert el.name == "button", (
f"#add-remote-instance-btn must be a