feat: reorganize settings dialog — add Multi-Device tab, enable/disable toggle, device name as page title

- Add multi_device_enabled: false to DEFAULT_SETTINGS in settings.py
- Add fifth 'Multi-Device' tab (data-tab='devices') to settings dialog sidebar
- Move #setting-device-name, #setting-remote-instances, #add-remote-instance-btn,
  #setting-view-mode, and #setting-view-scope out of Display/Sessions tabs into
  the new dedicated devices panel
- Add #setting-multi-device-enabled checkbox at top of Multi-Device tab;
  when unchecked, all other fields in the tab are greyed out and disabled
- Gate buildSources() remote instances behind multi_device_enabled (smart default:
  treated as enabled if remote_instances is non-empty for backward compat)
- Wire device name → document.title: updates title on openSettings() and on
  every keystroke in the device name input field
- Add _updateMultiDeviceFieldsState() helper to enable/disable fields
- Add #multi-device-fields CSS transition for smooth opacity animation
- Add 19 new tests (settings, HTML, JS, CSS) covering all new behavior
This commit is contained in:
Brian Krabach
2026-03-31 06:16:44 -07:00
parent 881b6cc9f3
commit 679416a5bf
8 changed files with 380 additions and 58 deletions
+60 -11
View File
@@ -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);
+33 -24
View File
@@ -90,6 +90,7 @@
<button class="settings-tab" data-tab="sessions">Sessions</button>
<button class="settings-tab" data-tab="notifications">Notifications</button>
<button class="settings-tab" data-tab="new-session">Commands</button>
<button class="settings-tab" data-tab="devices">Multi-Device</button>
</nav>
<div class="settings-content">
<div class="settings-panel" data-tab="display">
@@ -123,21 +124,6 @@
<option value="4">4</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-view-mode">View Mode</label>
<select id="setting-view-mode" class="settings-select">
<option value="flat">Flat</option>
<option value="grouped">Grouped</option>
<option value="filtered">Filtered</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-view-scope">View Scope</label>
<select id="setting-view-scope" class="settings-select">
<option value="local">Local</option>
<option value="server">Server</option>
</select>
</div>
</div>
<div class="settings-panel hidden" data-tab="sessions">
<div class="settings-field">
@@ -166,15 +152,6 @@
<label class="settings-label" for="setting-auto-open">Auto-open created sessions</label>
<input type="checkbox" id="setting-auto-open" class="settings-checkbox" checked />
</div>
<div class="settings-field">
<label class="settings-label" for="setting-device-name">Device Name</label>
<input type="text" id="setting-device-name" class="settings-input" placeholder="This device" />
</div>
<div class="settings-field settings-field--column">
<label class="settings-label">Remote Instances</label>
<div id="setting-remote-instances" class="settings-remote-list"></div>
<button id="add-remote-instance-btn" class="settings-action-btn">+ Add Remote</button>
</div>
</div>
<div class="settings-panel hidden" data-tab="notifications">
<div class="settings-field">
@@ -203,6 +180,38 @@
<button id="setting-delete-template-reset" class="settings-action-btn">Reset to default</button>
</div>
</div>
<div class="settings-panel hidden" data-tab="devices">
<div class="settings-field">
<label class="settings-label" for="setting-multi-device-enabled">Enable multi-device support</label>
<input type="checkbox" id="setting-multi-device-enabled" class="settings-checkbox" />
</div>
<div id="multi-device-fields">
<div class="settings-field">
<label class="settings-label" for="setting-device-name">Device Name</label>
<input type="text" id="setting-device-name" class="settings-input" placeholder="This device" />
</div>
<div class="settings-field">
<label class="settings-label" for="setting-view-mode">View Mode</label>
<select id="setting-view-mode" class="settings-select">
<option value="flat">Flat</option>
<option value="grouped">Grouped</option>
<option value="filtered">Filtered</option>
</select>
</div>
<div class="settings-field">
<label class="settings-label" for="setting-view-scope">View Scope</label>
<select id="setting-view-scope" class="settings-select">
<option value="local">Local</option>
<option value="server">Server</option>
</select>
</div>
<div class="settings-field settings-field--column">
<label class="settings-label">Remote Instances</label>
<div id="setting-remote-instances" class="settings-remote-list"></div>
<button id="add-remote-instance-btn" class="settings-action-btn">+ Add Remote</button>
</div>
</div>
</div>
</div>
</div>
</dialog>
+5
View File
@@ -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;
}
+1
View File
@@ -21,6 +21,7 @@ DEFAULT_SETTINGS: dict = {
"remote_instances": [],
"device_name": "",
"delete_session_template": "tmux kill-session -t {name}",
"multi_device_enabled": False,
}
+20
View File
@@ -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"
+147 -23
View File
@@ -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 <button>, got: {el.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="add-remote-instance-btn") is not None, (
"#add-remote-instance-btn 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="add-remote-instance-btn") is not None, (
"#add-remote-instance-btn must be inside the devices (Multi-Device) settings panel"
)
@@ -1206,10 +1206,134 @@ def test_html_view_mode_button_exists() -> None:
"""#view-mode-btn must exist in the overview header for cycling view modes."""
soup = _SOUP
btn = soup.find(id="view-mode-btn")
assert btn is not None, "Missing element with id='view-mode-btn' (view mode toggle button)"
assert btn is not None, (
"Missing element with id='view-mode-btn' (view mode toggle button)"
)
# Must be inside the overview header area
overview = soup.find(id="view-overview")
assert overview is not None, "Missing #view-overview"
assert overview.find(id="view-mode-btn") is not None, (
"#view-mode-btn must be inside #view-overview header"
)
# ============================================================
# Multi-Device tab (settings UI reorganization)
# ============================================================
def test_html_devices_tab_button_exists() -> None:
"""Settings dialog must contain a tab button with data-tab='devices' labeled 'Multi-Device'."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
tabs_container = dialog.find("nav", class_="settings-tabs")
assert tabs_container is not None, "Missing nav.settings-tabs"
tab = tabs_container.find("button", attrs={"data-tab": "devices"})
assert tab is not None, (
"Missing tab button with data-tab='devices' in settings-tabs"
)
tab_classes = tab.get("class") or []
assert "settings-tab" in tab_classes, (
f"devices tab button must have class 'settings-tab', has: {tab_classes}"
)
def test_html_devices_panel_exists() -> None:
"""A settings-panel with data-tab='devices' must exist inside #settings-dialog."""
soup = _SOUP
dialog = soup.find(id="settings-dialog")
assert dialog is not None, "Missing #settings-dialog"
panel = dialog.find(class_="settings-panel", attrs={"data-tab": "devices"})
assert panel is not None, (
"Missing settings-panel[data-tab='devices'] (Multi-Device tab panel)"
)
def test_html_devices_panel_has_enable_checkbox() -> None:
"""Multi-Device tab panel must contain #setting-multi-device-enabled checkbox."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
el = devices_panel.find(id="setting-multi-device-enabled")
assert el is not None, "Missing #setting-multi-device-enabled inside devices panel"
assert el.name == "input", (
f"#setting-multi-device-enabled must be an <input>, got: {el.name}"
)
assert el.get("type") == "checkbox", (
f"#setting-multi-device-enabled must be type='checkbox', got: {el.get('type')}"
)
def test_html_devices_panel_has_device_name() -> None:
"""Multi-Device tab panel must contain #setting-device-name text input."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
el = devices_panel.find(id="setting-device-name")
assert el is not None, "Missing #setting-device-name inside devices panel"
def test_html_devices_panel_has_remote_instances() -> None:
"""Multi-Device tab panel must contain #setting-remote-instances container."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
el = devices_panel.find(id="setting-remote-instances")
assert el is not None, "Missing #setting-remote-instances inside devices panel"
def test_html_devices_panel_has_add_remote_btn() -> None:
"""Multi-Device tab panel must contain #add-remote-instance-btn button."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
btn = devices_panel.find(id="add-remote-instance-btn")
assert btn is not None, "Missing #add-remote-instance-btn inside devices panel"
assert btn.name == "button", (
f"#add-remote-instance-btn must be a <button>, got: {btn.name}"
)
def test_html_devices_panel_has_view_mode() -> None:
"""Multi-Device tab panel must contain #setting-view-mode select."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
el = devices_panel.find(id="setting-view-mode")
assert el is not None, "Missing #setting-view-mode inside devices panel"
assert el.name == "select", f"#setting-view-mode must be a <select>, got: {el.name}"
def test_html_devices_panel_has_view_scope() -> None:
"""Multi-Device tab panel must contain #setting-view-scope select."""
soup = _SOUP
devices_panel = soup.find("div", attrs={"data-tab": "devices"})
assert devices_panel is not None, "Missing devices panel (data-tab='devices')"
el = devices_panel.find(id="setting-view-scope")
assert el is not None, "Missing #setting-view-scope inside devices panel"
assert el.name == "select", (
f"#setting-view-scope must be a <select>, got: {el.name}"
)
def test_html_display_panel_no_view_mode() -> None:
"""Display panel must NOT contain #setting-view-mode (moved to Multi-Device tab)."""
soup = _SOUP
display_panel = soup.find("div", attrs={"data-tab": "display"})
assert display_panel is not None, "Missing display panel"
el = display_panel.find(id="setting-view-mode")
assert el is None, (
"#setting-view-mode must NOT be in the display panel (moved to Multi-Device tab)"
)
def test_html_display_panel_no_view_scope() -> None:
"""Display panel must NOT contain #setting-view-scope (moved to Multi-Device tab)."""
soup = _SOUP
display_panel = soup.find("div", attrs={"data-tab": "display"})
assert display_panel is not None, "Missing display panel"
el = display_panel.find(id="setting-view-scope")
assert el is None, (
"#setting-view-scope must NOT be in the display panel (moved to Multi-Device tab)"
)
+78
View File
@@ -2444,3 +2444,81 @@ def test_build_remote_instance_row_name_input_aria_label() -> None:
assert "Remote instance display name" in body, (
"nameInput must have aria-label='Remote instance display name' for screen-reader accessibility"
)
# ============================================================
# Multi-Device tab — settings UI reorganization
# ============================================================
def test_open_settings_populates_multi_device_enabled() -> None:
"""openSettings must populate #setting-multi-device-enabled from server settings."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found in app.js"
body = match.group(1)
assert "setting-multi-device-enabled" in body, (
"openSettings must populate #setting-multi-device-enabled checkbox"
)
assert "multi_device_enabled" in body, (
"openSettings must reference multi_device_enabled from server settings"
)
def test_open_settings_updates_document_title() -> None:
"""openSettings must update document.title from the device_name setting."""
match = re.search(
r"function openSettings\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "openSettings function not found in app.js"
body = match.group(1)
assert "document.title" in body, (
"openSettings must update document.title from device_name"
)
def test_bind_static_event_listeners_binds_multi_device_enabled() -> None:
"""bindStaticEventListeners must bind change on #setting-multi-device-enabled."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "setting-multi-device-enabled" in body, (
"bindStaticEventListeners must bind #setting-multi-device-enabled change event"
)
def test_build_sources_checks_multi_device_enabled() -> None:
"""buildSources must check multi_device_enabled before adding remote sources."""
match = re.search(
r"function buildSources\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "buildSources function not found in app.js"
body = match.group(1)
assert "multi_device_enabled" in body, (
"buildSources must check multi_device_enabled to gate remote sources"
)
def test_device_name_change_updates_document_title() -> None:
"""device name change handler in bindStaticEventListeners must update document.title."""
match = re.search(
r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "bindStaticEventListeners function not found"
body = match.group(1)
assert "document.title" in body, (
"bindStaticEventListeners device-name handler must update document.title"
)
+36
View File
@@ -270,3 +270,39 @@ def test_delete_session_template_patchable():
# Verify it was persisted
loaded = load_settings()
assert loaded["delete_session_template"] == custom
# ============================================================
# Multi-device enabled flag (task: settings UI reorganization)
# ============================================================
def test_defaults_include_multi_device_enabled():
"""DEFAULT_SETTINGS must include 'multi_device_enabled' key initialised to False."""
assert "multi_device_enabled" in DEFAULT_SETTINGS, (
"DEFAULT_SETTINGS must include 'multi_device_enabled'"
)
assert DEFAULT_SETTINGS["multi_device_enabled"] is False, (
f"multi_device_enabled default must be False, got: {DEFAULT_SETTINGS['multi_device_enabled']!r}"
)
def test_load_returns_multi_device_enabled_default():
"""load_settings() must return multi_device_enabled with default value of False."""
result = load_settings()
assert "multi_device_enabled" in result, (
"load_settings() must include 'multi_device_enabled'"
)
assert result["multi_device_enabled"] is False, (
f"load_settings() multi_device_enabled must default to False, got: {result['multi_device_enabled']!r}"
)
def test_multi_device_enabled_patchable():
"""patch_settings() must accept and persist multi_device_enabled."""
result = patch_settings({"multi_device_enabled": True})
assert result["multi_device_enabled"] is True, (
f"patch_settings() must accept multi_device_enabled=True, got: {result['multi_device_enabled']!r}"
)
loaded = load_settings()
assert loaded["multi_device_enabled"] is True