feat: add device select dropdown to showNewSessionInput

- 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
This commit is contained in:
Brian Krabach
2026-04-04 07:51:21 -07:00
parent 97f59fda84
commit e12a965cdf
2 changed files with 154 additions and 4 deletions
+46 -4
View File
@@ -1910,17 +1910,56 @@ function _createSessionInput() {
} }
/** /**
* Replace the header + button with an inline text input for session naming. * Create an optional device <select> for multi-device session creation.
* Hides the button, inserts the input before it, and focuses it. * Returns null when multi_device_enabled is false or remote_instances is empty.
* On Enter: if name is non-empty after trim, calls createNewSession(name). * @returns {HTMLSelectElement|null}
*/
function _createDeviceSelect() {
const ss = _serverSettings || {};
const remotes = ss.remote_instances;
if (!ss.multi_device_enabled || !remotes || remotes.length === 0) {
return null;
}
const select = document.createElement('select');
select.className = 'new-session-device-select';
// Local device option
const localOpt = document.createElement('option');
localOpt.value = '';
localOpt.textContent = ss.device_name || 'Local';
select.appendChild(localOpt);
// Remote instance options
for (var i = 0; i < remotes.length; i++) {
var opt = document.createElement('option');
opt.value = String(i);
opt.textContent = remotes[i].name || remotes[i].url || 'Remote ' + i;
if (_activeFilterDevice === remotes[i].name || _activeFilterDevice === remotes[i].url) {
opt.selected = true;
select.value = String(i);
}
select.appendChild(opt);
}
return select;
}
/**
* Replace the header + button with an inline text input (and optional device
* select) for session naming. Hides the button, inserts controls before it,
* and focuses the input.
* On Enter: if name is non-empty after trim, calls createNewSession(name, remoteId).
* On Escape: restores the button (cleanup only). * On Escape: restores the button (cleanup only).
* On blur: delayed cleanup (150ms) to allow click handlers. * On blur: delayed cleanup (150ms) to allow click handlers.
* @param {HTMLElement} btn - The button element to replace temporarily. * @param {HTMLElement} btn - The button element to replace temporarily.
*/ */
function showNewSessionInput(btn) { function showNewSessionInput(btn) {
const select = _createDeviceSelect();
const input = _createSessionInput(); const input = _createSessionInput();
function cleanup() { function cleanup() {
if (select && select.parentNode) select.parentNode.removeChild(select);
if (input.parentNode) input.parentNode.removeChild(input); if (input.parentNode) input.parentNode.removeChild(input);
btn.style.display = ''; btn.style.display = '';
} }
@@ -1928,8 +1967,9 @@ function showNewSessionInput(btn) {
input.addEventListener('keydown', function (e) { input.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
const name = input.value.trim(); const name = input.value.trim();
const remoteId = select ? select.value : '';
cleanup(); cleanup();
if (name) createNewSession(name); if (name) createNewSession(name, remoteId);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
cleanup(); cleanup();
} }
@@ -1940,6 +1980,7 @@ function showNewSessionInput(btn) {
}); });
btn.style.display = 'none'; btn.style.display = 'none';
if (select) btn.parentNode.insertBefore(select, btn);
btn.parentNode.insertBefore(input, btn); btn.parentNode.insertBefore(input, btn);
input.focus(); input.focus();
} }
@@ -2483,6 +2524,7 @@ if (typeof module !== 'undefined' && module.exports) {
// Fetch wrapper // Fetch wrapper
api, api,
// Header + button with inline name input // Header + button with inline name input
_createDeviceSelect,
showNewSessionInput, showNewSessionInput,
showFabSessionInput, showFabSessionInput,
createNewSession, createNewSession,
+108
View File
@@ -4103,3 +4103,111 @@ test('updatePageTitle is called from pollSessions', () => {
const pollFn = source.substring(source.indexOf('async function pollSessions'), source.indexOf('async function pollSessions') + 700); const pollFn = source.substring(source.indexOf('async function pollSessions'), source.indexOf('async function pollSessions') + 700);
assert.ok(pollFn.includes('updatePageTitle'), 'pollSessions must call updatePageTitle'); assert.ok(pollFn.includes('updatePageTitle'), 'pollSessions must call updatePageTitle');
}); });
// --- _createDeviceSelect and showNewSessionInput multi-device tests ---
test('_createDeviceSelect builds a <select> with Local + remote options', () => {
// Verify function is exported
assert.strictEqual(typeof app._createDeviceSelect, 'function', '_createDeviceSelect must be exported');
// Set up server settings with multi_device_enabled + remote_instances + device_name
app._setServerSettings({
multi_device_enabled: true,
remote_instances: [{ name: 'Remote A', url: 'http://a' }],
device_name: 'MyDevice',
});
// Mock document.createElement to capture created elements
const origCE = globalThis.document.createElement;
const builtOptions = [];
let selectEl = null;
globalThis.document.createElement = (tag) => {
if (tag === 'select') {
selectEl = {
tagName: 'SELECT',
className: '',
value: '',
options: builtOptions,
appendChild: (child) => { builtOptions.push(child); },
addEventListener: () => {},
};
return selectEl;
}
// option elements
return { tagName: tag.toUpperCase(), value: '', textContent: '', selected: false };
};
const result = app._createDeviceSelect();
globalThis.document.createElement = origCE;
assert.ok(result !== null, '_createDeviceSelect must return non-null when multi_device_enabled + remotes present');
assert.strictEqual(result.className, 'new-session-device-select', 'select must have className new-session-device-select');
assert.strictEqual(builtOptions.length, 2, 'must have 2 options: local + 1 remote');
assert.strictEqual(builtOptions[0].value, '', 'first option value must be empty string (local)');
assert.strictEqual(builtOptions[0].textContent, 'MyDevice', 'first option text must use device_name');
assert.strictEqual(builtOptions[1].value, '0', 'remote option value must be "0" (String(index))');
assert.strictEqual(builtOptions[1].textContent, 'Remote A', 'remote option text must use remote.name');
});
test('showNewSessionInput creates device select when multi_device_enabled with remotes', () => {
app._setServerSettings({
multi_device_enabled: true,
remote_instances: [{ name: 'Remote B', url: 'http://b' }],
device_name: 'LocalDev',
});
const origCE = globalThis.document.createElement;
const createdTags = [];
const insertedEls = [];
globalThis.document.createElement = (tag) => {
createdTags.push(tag);
return {
tagName: tag.toUpperCase(),
className: '',
type: '',
placeholder: '',
autocomplete: '',
spellcheck: false,
value: '',
style: {},
options: [],
appendChild: () => {},
addEventListener: () => {},
focus: () => {},
};
};
const btn = {
style: {},
parentNode: {
insertBefore: (el) => { insertedEls.push(el); },
},
};
app.showNewSessionInput(btn);
globalThis.document.createElement = origCE;
assert.ok(createdTags.includes('select'), 'showNewSessionInput must create a <select> element when multi_device_enabled');
});
test('showNewSessionInput passes remoteId from device select to createNewSession', () => {
const source = fs.readFileSync(new URL('../app.js', import.meta.url), 'utf8');
// Extract showNewSessionInput function body
const fnStart = source.indexOf('function showNewSessionInput(');
assert.ok(fnStart !== -1, 'showNewSessionInput function must exist');
const fnBody = source.substring(fnStart, fnStart + 1200);
// Must call _createDeviceSelect
assert.ok(fnBody.includes('_createDeviceSelect'), 'showNewSessionInput must call _createDeviceSelect');
// Must read remoteId from select.value (or equivalent)
assert.ok(
fnBody.includes('remoteId') && (fnBody.includes('select.value') || fnBody.includes('sel.value')),
'showNewSessionInput Enter handler must read remoteId from select element value',
);
// Must call createNewSession with two arguments (name and remoteId)
assert.ok(
fnBody.includes('createNewSession(name') && fnBody.includes('remoteId'),
'showNewSessionInput must call createNewSession with name and remoteId arguments',
);
});