diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index 6687151..5821e1d 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -1184,6 +1184,57 @@ function buildSources(settings) {
return sources;
}
+/**
+ * Build a single remote instance row element with URL input, name input, and remove button.
+ * @param {string} url - remote instance URL
+ * @param {string} name - remote instance display name
+ * @returns {HTMLDivElement}
+ */
+function _buildRemoteInstanceRow(url, name) {
+ var row = document.createElement('div');
+ row.className = 'settings-remote-row';
+ var urlInput = document.createElement('input');
+ urlInput.type = 'text';
+ urlInput.className = 'settings-remote-url';
+ urlInput.placeholder = 'http://192.168.1.x:8000';
+ urlInput.value = url || '';
+ var nameInput = document.createElement('input');
+ nameInput.type = 'text';
+ nameInput.className = 'settings-remote-name';
+ nameInput.placeholder = 'Device name';
+ nameInput.value = name || '';
+ var removeBtn = document.createElement('button');
+ removeBtn.className = 'settings-remote-remove';
+ removeBtn.textContent = '\u00d7';
+ removeBtn.setAttribute('aria-label', 'Remove remote instance');
+ row.appendChild(urlInput);
+ row.appendChild(nameInput);
+ row.appendChild(removeBtn);
+ return row;
+}
+
+/**
+ * Read remote instance rows from the DOM and save to server settings.
+ * Rebuilds _sources after saving so polling updates immediately.
+ */
+function _saveRemoteInstances() {
+ var container = $('setting-remote-instances');
+ if (!container) return;
+ var instances = [];
+ container.querySelectorAll('.settings-remote-row').forEach(function(row) {
+ var urlEl = row.querySelector('.settings-remote-url');
+ var nameEl = row.querySelector('.settings-remote-name');
+ var url = (urlEl && urlEl.value) ? urlEl.value.trim() : '';
+ var name = (nameEl && nameEl.value) ? nameEl.value.trim() : '';
+ if (url) {
+ instances.push({ url: url, name: name });
+ }
+ });
+ patchServerSetting('remote_instances', instances).then(function() {
+ _sources = buildSources(_serverSettings);
+ });
+}
+
// ─── Settings dialog ──────────────────────────────────────────────────────────
/**
@@ -1403,6 +1454,22 @@ function openSettings() {
autoOpenEl.checked = ss && ss.auto_open !== undefined ? !!ss.auto_open : true;
}
+ // Device name
+ const deviceNameEl = $('setting-device-name');
+ if (deviceNameEl) {
+ deviceNameEl.value = (ss && ss.device_name) || '';
+ }
+
+ // Remote instances
+ const remoteInstancesEl = $('setting-remote-instances');
+ if (remoteInstancesEl) {
+ remoteInstancesEl.innerHTML = '';
+ var remotes = (ss && ss.remote_instances) || [];
+ remotes.forEach(function(r) {
+ remoteInstancesEl.appendChild(_buildRemoteInstanceRow(r.url || '', r.name || ''));
+ });
+ }
+
// New Session tab - populate template textarea
const templateEl = $('setting-template');
if (templateEl) {
@@ -1882,6 +1949,51 @@ function bindStaticEventListeners() {
patchServerSetting('new_session_template', NEW_SESSION_DEFAULT_TEMPLATE);
});
+ // Sessions tab — device name with 500ms debounce
+ var _deviceNameDebounceTimer;
+ on($('setting-device-name'), 'input', function() {
+ clearTimeout(_deviceNameDebounceTimer);
+ var val = this.value;
+ _deviceNameDebounceTimer = setTimeout(function() {
+ patchServerSetting('device_name', val).then(function() {
+ _sources = buildSources(_serverSettings);
+ });
+ }, 500);
+ });
+
+ // Sessions tab — add remote instance button
+ on($('add-remote-instance-btn'), 'click', function() {
+ var container = $('setting-remote-instances');
+ if (container) {
+ container.appendChild(_buildRemoteInstanceRow('', ''));
+ }
+ });
+
+ // Sessions tab — delegated remove handler on remote instances container
+ var remoteInstancesContainer = $('setting-remote-instances');
+ if (remoteInstancesContainer) {
+ remoteInstancesContainer.addEventListener('click', function(e) {
+ var removeBtn = e.target.closest && e.target.closest('.settings-remote-remove');
+ if (!removeBtn) return;
+ var row = removeBtn.closest('.settings-remote-row');
+ if (row) {
+ row.remove();
+ _saveRemoteInstances();
+ }
+ });
+
+ // Delegated input save with debounce for remote instance URL/name fields
+ var _remoteDebounceTimer;
+ remoteInstancesContainer.addEventListener('input', function(e) {
+ var input = e.target.closest && e.target.closest('.settings-remote-url, .settings-remote-name');
+ if (!input) return;
+ clearTimeout(_remoteDebounceTimer);
+ _remoteDebounceTimer = setTimeout(function() {
+ _saveRemoteInstances();
+ }, 500);
+ });
+ }
+
// Filter bar — delegated click handler (pills are re-rendered each poll)
var filterBarEl = $('filter-bar');
if (filterBarEl) {
diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html
index 3099e9f..b4a3906 100644
--- a/muxplex/frontend/index.html
+++ b/muxplex/frontend/index.html
@@ -164,6 +164,15 @@
+
diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css
index 265417e..8537afd 100644
--- a/muxplex/frontend/style.css
+++ b/muxplex/frontend/style.css
@@ -1451,3 +1451,86 @@ body {
display: none;
}
}
+
+/* ============================================================
+ Remote Instances UI (task-15)
+ ============================================================ */
+
+.settings-input {
+ flex: 1;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 13px;
+ padding: 5px 8px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.settings-input:focus {
+ border-color: var(--accent);
+}
+
+.settings-remote-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+}
+
+.settings-remote-row {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+.settings-remote-url {
+ flex: 2;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 13px;
+ padding: 5px 8px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.settings-remote-url:focus {
+ border-color: var(--accent);
+}
+
+.settings-remote-name {
+ flex: 1;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text);
+ font-size: 13px;
+ padding: 5px 8px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+
+.settings-remote-name:focus {
+ border-color: var(--accent);
+}
+
+.settings-remote-remove {
+ flex-shrink: 0;
+ background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text-muted);
+ cursor: pointer;
+ font-size: 16px;
+ line-height: 1;
+ padding: 4px 8px;
+ transition: color 0.15s, border-color 0.15s;
+}
+
+.settings-remote-remove:hover {
+ border-color: var(--accent);
+ color: var(--text);
+}
diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py
index 3ff7ff8..9990b42 100644
--- a/muxplex/tests/test_frontend_css.py
+++ b/muxplex/tests/test_frontend_css.py
@@ -1681,3 +1681,20 @@ def test_css_sidebar_footer() -> None:
css = read_css()
for cls in (".sidebar-footer", ".sidebar-new-btn"):
assert cls in css, f"Missing CSS selector '{cls}'"
+
+
+# ─── Remote Instances UI (task-15) ────────────────────────────────────────────
+
+
+def test_css_remote_instances_classes() -> None:
+ """CSS classes for remote instances management must exist in style.css."""
+ css = read_css()
+ for cls in (
+ ".settings-remote-list",
+ ".settings-remote-row",
+ ".settings-remote-url",
+ ".settings-remote-name",
+ ".settings-remote-remove",
+ ".settings-input",
+ ):
+ assert cls in css, f"Missing CSS selector '{cls}' — required for Remote Instances UI"
diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py
index 007b045..b04e937 100644
--- a/muxplex/tests/test_frontend_html.py
+++ b/muxplex/tests/test_frontend_html.py
@@ -1062,3 +1062,50 @@ def test_html_settings_close_btn_exists() -> None:
assert dialog.find(id="settings-close-btn") is not None, (
"#settings-close-btn must be a descendant of #settings-dialog"
)
+
+
+# ============================================================
+# Remote Instances UI (task-15-remote-instances)
+# ============================================================
+
+
+def test_html_sessions_tab_device_name_input() -> None:
+ """Sessions 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"
+ )
+
+
+def test_html_sessions_tab_remote_instances_container() -> None:
+ """Sessions 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"
+ )
+
+
+def test_html_sessions_tab_add_remote_instance_btn() -> None:
+ """Sessions 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