From 84fb7826d79f87462e1896ac15fcc3a2daa9a483 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Mon, 30 Mar 2026 01:08:19 -0700 Subject: [PATCH] feat: add header + button with inline name input for session creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add showNewSessionInput(btn): creates inline text input with class 'new-session-input', placeholder 'Session name…', autocomplete off, spellcheck false; hides button, inserts input before it, focuses it. Handles Enter (create), Escape (cancel), and blur (150ms delayed cleanup). - Add createNewSession(name): POSTs to /api/sessions, shows toast with created session name, calls pollSessions(), and if ss.auto_open_created !== false calls openSession(data.name) after 500ms delay. - Bind #new-session-btn click to showNewSessionInput in bindStaticEventListeners. - Export showNewSessionInput and createNewSession from module.exports. - Add .new-session-input CSS (bg, border 1px solid accent, border-radius 4px, font-size 13px, font-ui, padding 4px 10px, width 180px, outline none) and ::placeholder { color: var(--text-dim) } in style.css. - Add 28 new tests covering all spec requirements (19 JS structural, 9 CSS). --- muxplex/frontend/app.js | 69 ++++++++ muxplex/frontend/style.css | 20 +++ muxplex/tests/test_frontend_css.py | 138 ++++++++++++++++ muxplex/tests/test_frontend_js.py | 253 +++++++++++++++++++++++++++++ 4 files changed, 480 insertions(+) diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index eab916f..78151b6 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1274,12 +1274,78 @@ function updateSessionPill(sessions) { } } +// ─── Header + button with inline name input ──────────────────────────────────── + +/** + * Replace the header + button with an inline text input for session naming. + * Hides the button, inserts the input before it, and focuses it. + * On Enter: if name is non-empty after trim, calls createNewSession(name). + * On Escape: restores the button (cleanup only). + * On blur: delayed cleanup (150ms) to allow click handlers. + * @param {HTMLElement} btn - The button element to replace temporarily. + */ +function showNewSessionInput(btn) { + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'new-session-input'; + input.placeholder = 'Session name\u2026'; + input.autocomplete = 'off'; + input.spellcheck = false; + + function cleanup() { + if (input.parentNode) input.parentNode.removeChild(input); + btn.style.display = ''; + } + + input.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + const name = input.value.trim(); + cleanup(); + if (name) createNewSession(name); + } else if (e.key === 'Escape') { + cleanup(); + } + }); + + input.addEventListener('blur', function () { + setTimeout(cleanup, 150); + }); + + btn.style.display = 'none'; + btn.parentNode.insertBefore(input, btn); + input.focus(); +} + +/** + * Create a new tmux session via POST /api/sessions. + * Shows a toast with the created session name, calls pollSessions() to refresh, + * and if ss.auto_open_created !== false, calls openSession(data.name) after 500ms. + * @param {string} name - The session name to create. + * @returns {Promise} + */ +async function createNewSession(name) { + try { + const res = await api('POST', '/api/sessions', { name }); + const data = await res.json(); + showToast('Created: ' + (data.name || name)); + await pollSessions(); + const ss = _serverSettings || {}; + if (ss.auto_open_created !== false) { + setTimeout(() => openSession(data.name || name), 500); + } + } catch (err) { + showToast(err.message || 'Failed to create session'); + } +} + /** * Bind all static (once-only) event listeners for the app UI. * Called once after restoreState() resolves. */ function bindStaticEventListeners() { on($('back-btn'), 'click', closeSession); + var newSessionBtn = $('new-session-btn'); + if (newSessionBtn) on(newSessionBtn, 'click', function() { showNewSessionInput(newSessionBtn); }); on($('sidebar-toggle-btn'), 'click', toggleSidebar); on($('sidebar-collapse-btn'), 'click', toggleSidebar); bindSidebarClickAway(); @@ -1503,6 +1569,9 @@ if (typeof module !== 'undefined' && module.exports) { // Server settings loadServerSettings, patchServerSetting, + // Header + button with inline name input + showNewSessionInput, + createNewSession, // Test-only helpers _setCurrentSessions, _setViewMode, diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 6f496a2..75cb162 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -1154,6 +1154,26 @@ body { cursor: not-allowed; } +/* ============================================================ + Header + button inline name input (new-session-btn) + ============================================================ */ + +.new-session-input { + background: var(--bg); + border: 1px solid var(--accent); + border-radius: 4px; + font-size: 13px; + font-family: var(--font-ui); + padding: 4px 10px; + width: 180px; + outline: none; + color: var(--text); +} + +.new-session-input::placeholder { + color: var(--text-dim); +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 2f71af5..05f34ee 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -1252,3 +1252,141 @@ def test_css_settings_helper_italic() -> None: assert "font-style: italic" in body or "font-style:italic" in body, ( ".settings-helper must have font-style: italic" ) + + +# ─── .new-session-input (task-4-header-plus-button) ────────────────────────── + + +def test_css_new_session_input_rule_exists() -> None: + """.new-session-input CSS rule must exist in style.css.""" + css = read_css() + assert ".new-session-input" in css, ( + ".new-session-input CSS rule must exist in style.css" + ) + + +def test_css_new_session_input_has_border() -> None: + """.new-session-input must have a border (1px solid accent).""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input rule not found" + body = match.group(1) + assert "border" in body, ".new-session-input must have a border property" + assert "accent" in body or "#00D9F5" in body or "1px solid" in body, ( + ".new-session-input border must reference accent color" + ) + + +def test_css_new_session_input_has_border_radius() -> None: + """.new-session-input must have border-radius: 4px.""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input rule not found" + body = match.group(1) + assert "border-radius" in body, ".new-session-input must have border-radius" + assert "4px" in body, ".new-session-input border-radius must be 4px" + + +def test_css_new_session_input_has_font_size() -> None: + """.new-session-input must have font-size: 13px.""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input rule not found" + body = match.group(1) + assert "font-size" in body, ".new-session-input must have font-size" + assert "13px" in body, ".new-session-input font-size must be 13px" + + +def test_css_new_session_input_has_padding() -> None: + """.new-session-input must have padding: 4px 10px.""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input rule not found" + body = match.group(1) + assert "padding" in body, ".new-session-input must have padding" + assert "4px" in body and "10px" in body, ( + ".new-session-input padding must be 4px 10px" + ) + + +def test_css_new_session_input_has_width() -> None: + """.new-session-input must have width: 180px.""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input rule not found" + body = match.group(1) + assert "width" in body, ".new-session-input must have width" + assert "180px" in body, ".new-session-input width must be 180px" + + +def test_css_new_session_input_has_outline_none() -> None: + """.new-session-input must have outline: none.""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input rule not found" + body = match.group(1) + assert "outline" in body and "none" in body, ( + ".new-session-input must have outline: none" + ) + + +def test_css_new_session_input_placeholder_rule_exists() -> None: + """.new-session-input::placeholder CSS rule must exist.""" + css = read_css() + assert ".new-session-input::placeholder" in css or \ + ".new-session-input::-webkit-input-placeholder" in css, ( + ".new-session-input::placeholder CSS rule must exist" + ) + + +def test_css_new_session_input_placeholder_color() -> None: + """.new-session-input::placeholder must have color: var(--text-dim).""" + import re + + css = read_css() + match = re.search( + r"\.new-session-input::placeholder\s*\{([^}]*)\}", + css, + re.DOTALL, + ) + assert match, ".new-session-input::placeholder rule not found" + body = match.group(1) + assert "color" in body and ("text-dim" in body or "--text-dim" in body), ( + ".new-session-input::placeholder must have color: var(--text-dim)" + ) diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 19e213d..ebb27d1 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -1523,3 +1523,256 @@ def test_bind_static_event_listeners_reset_patches_server() -> None: assert "NEW_SESSION_DEFAULT_TEMPLATE" in body, ( "bindStaticEventListeners reset handler must use NEW_SESSION_DEFAULT_TEMPLATE" ) + + +# ─── Header + button with inline name input (task-4-header-plus-button) ────── + + +def test_show_new_session_input_function_exists() -> None: + """showNewSessionInput function must exist in app.js.""" + assert "function showNewSessionInput" in _JS, ( + "showNewSessionInput must be defined in app.js" + ) + + +def test_create_new_session_function_exists() -> None: + """createNewSession function must exist in app.js.""" + assert "function createNewSession" in _JS, ( + "createNewSession must be defined in app.js" + ) + + +def test_show_new_session_input_creates_input_with_class() -> None: + """showNewSessionInput must create an input element with class 'new-session-input'.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "new-session-input" in body, ( + "showNewSessionInput must create an input with class 'new-session-input'" + ) + + +def test_show_new_session_input_sets_placeholder() -> None: + """showNewSessionInput must set placeholder to 'Session name…'.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "Session name" in body, ( + "showNewSessionInput must set placeholder containing 'Session name'" + ) + + +def test_show_new_session_input_disables_autocomplete() -> None: + """showNewSessionInput must set autocomplete off on the input.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "autocomplete" in body.lower() and "off" in body.lower(), ( + "showNewSessionInput must set autocomplete off" + ) + + +def test_show_new_session_input_disables_spellcheck() -> None: + """showNewSessionInput must set spellcheck false on the input.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "spellcheck" in body.lower() and "false" in body.lower(), ( + "showNewSessionInput must set spellcheck false" + ) + + +def test_show_new_session_input_hides_button() -> None: + """showNewSessionInput must hide the button.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + # Must hide the button (display none or style.display) + assert "display" in body or "style.display" in body or "hidden" in body, ( + "showNewSessionInput must hide the button" + ) + + +def test_show_new_session_input_focuses_input() -> None: + """showNewSessionInput must call focus() on the input.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert ".focus()" in body or "focus()" in body, ( + "showNewSessionInput must call focus() on the input" + ) + + +def test_show_new_session_input_handles_enter_key() -> None: + """showNewSessionInput must handle Enter key to create session.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "Enter" in body, "showNewSessionInput must handle Enter key" + assert "createNewSession" in body, ( + "showNewSessionInput must call createNewSession on Enter" + ) + + +def test_show_new_session_input_handles_escape_key() -> None: + """showNewSessionInput must handle Escape key to cancel.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "Escape" in body, "showNewSessionInput must handle Escape key" + + +def test_show_new_session_input_handles_blur_with_delay() -> None: + """showNewSessionInput must handle blur with 150ms delay.""" + match = re.search( + r"function showNewSessionInput\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showNewSessionInput function not found" + body = match.group(1) + assert "blur" in body, "showNewSessionInput must handle blur event" + assert "150" in body, ( + "showNewSessionInput must use 150ms delay on blur" + ) + + +def test_create_new_session_posts_to_api_sessions() -> None: + """createNewSession must POST to /api/sessions.""" + match = re.search( + r"async function createNewSession\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "createNewSession function not found" + body = match.group(1) + assert "/api/sessions" in body, "createNewSession must POST to /api/sessions" + assert "POST" in body, "createNewSession must use POST method" + + +def test_create_new_session_shows_toast() -> None: + """createNewSession must call showToast.""" + match = re.search( + r"async function createNewSession\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "createNewSession function not found" + body = match.group(1) + assert "showToast" in body, "createNewSession must call showToast" + + +def test_create_new_session_calls_poll_sessions() -> None: + """createNewSession must call pollSessions.""" + match = re.search( + r"async function createNewSession\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "createNewSession function not found" + body = match.group(1) + assert "pollSessions" in body, "createNewSession must call pollSessions" + + +def test_create_new_session_auto_opens_session() -> None: + """createNewSession must call openSession when auto_open_created is not false.""" + match = re.search( + r"async function createNewSession\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "createNewSession function not found" + body = match.group(1) + assert "openSession" in body, "createNewSession must call openSession" + assert "auto_open_created" in body, ( + "createNewSession must check ss.auto_open_created" + ) + + +def test_create_new_session_auto_open_delay() -> None: + """createNewSession must use a 500ms delay before calling openSession.""" + match = re.search( + r"async function createNewSession\s*\(\w+\)\s*\{(.*?)(?=\nasync function |\nfunction |\n// )", + _JS, + re.DOTALL, + ) + assert match, "createNewSession function not found" + body = match.group(1) + assert "500" in body, "createNewSession must use 500ms delay before openSession" + + +def test_bind_static_event_listeners_binds_new_session_btn() -> None: + """bindStaticEventListeners must bind click on new-session-btn to showNewSessionInput.""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "new-session-btn" in body, ( + "bindStaticEventListeners must bind new-session-btn click" + ) + assert "showNewSessionInput" in body, ( + "bindStaticEventListeners must call showNewSessionInput for new-session-btn" + ) + + +def test_exports_show_new_session_input() -> None: + """module.exports must export showNewSessionInput.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "showNewSessionInput" in exports, ( + "module.exports must export showNewSessionInput" + ) + + +def test_exports_create_new_session() -> None: + """module.exports must export createNewSession.""" + match = re.search( + r"module\.exports\s*=\s*\{(.*?)\};", + _JS, + re.DOTALL, + ) + assert match, "module.exports block not found" + exports = match.group(1) + assert "createNewSession" in exports, ( + "module.exports must export createNewSession" + )