From f8af4ecc6d93d1346720212ea1850c1fc3a925bd Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:14:54 -0400 Subject: [PATCH] feat(mobile): prioritize titlebar session title with tap-to-reveal and long-press actions (#4520) --- static/panels.js | 81 ++++++++++++++++++- static/style.css | 4 + tests/test_mobile_titlebar_session_actions.py | 28 +++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 tests/test_mobile_titlebar_session_actions.py diff --git a/static/panels.js b/static/panels.js index 3fb11cf4..25644d02 100644 --- a/static/panels.js +++ b/static/panels.js @@ -57,7 +57,7 @@ function syncAppTitlebar() { if (panel === 'chat' && typeof S !== 'undefined' && S && S.session) { mainText = S.session.title || (typeof t === 'function' ? t('untitled') : 'Untitled'); const vis = Array.isArray(S.messages) ? S.messages.filter(m => m && m.role && m.role !== 'tool') : []; - if (typeof t === 'function') subText = t('n_messages', vis.length); + subText = String(vis.length); sourceLabel = S.session.source_label || S.session.source_tag || S.session.raw_source || ''; // Recovered sidecars stamp source_label 'WebUI' (api/session_recovery.py); don't badge a native session as its own source (#3338). if (/^webui$/i.test(sourceLabel)) sourceLabel = ''; @@ -151,6 +151,85 @@ function syncAppTitlebar() { inp.select(); }; } + + // Mobile touch interactions — wired once per element lifetime. + if ('ontouchstart' in window && !titleEl._mobileTouchWired) { + titleEl._mobileTouchWired = true; + + // Tap-to-reveal full title popover + let _titlePopover = null; + const _dismissTitlePopover = () => { + if (_titlePopover) { _titlePopover.remove(); _titlePopover = null; } + }; + titleEl.addEventListener('click', function _onTitleClick(e) { + if (_renamingAppTitlebar) return; + if (_titlePopover) { + _dismissTitlePopover(); + if (typeof titleEl.ondblclick === 'function') titleEl.ondblclick(e); + return; + } + e.stopPropagation(); + const pop = document.createElement('div'); + pop.className = 'app-titlebar-title-popover'; + pop.textContent = (S && S.session && S.session.title) || + (typeof t === 'function' ? t('untitled') : 'Untitled'); + document.body.appendChild(pop); + const rect = titleEl.getBoundingClientRect(); + pop.style.top = (rect.bottom + 6) + 'px'; + pop.style.left = Math.max(8, rect.left) + 'px'; + pop.style.maxWidth = (window.innerWidth - 16) + 'px'; + _titlePopover = pop; + const _outside = (ev) => { + if (!pop.contains(ev.target) && ev.target !== titleEl) { + _dismissTitlePopover(); + document.removeEventListener('click', _outside, true); + } + }; + setTimeout(() => document.addEventListener('click', _outside, true), 0); + }, { passive: true }); + + // Long-press → session action menu (mirrors project-chip pattern, sessions.js:5362-5407) + if (panel === 'chat' && S && S.session && !S.session.read_only && !S.session.is_read_only && + typeof _openSessionActionMenu === 'function') { + let _lpTimer = null; + let _lpHandled = false; + let _lpStartX = 0, _lpStartY = 0; + const _lpDelay = typeof SESSION_LONG_PRESS_DELAY_MS !== 'undefined' ? + SESSION_LONG_PRESS_DELAY_MS : 400; + titleEl.addEventListener('touchstart', (e) => { + const touch = e.changedTouches && e.changedTouches[0]; + if (!touch) return; + if (_lpTimer) { clearTimeout(_lpTimer); _lpTimer = null; } + _lpHandled = false; _lpStartX = touch.clientX; _lpStartY = touch.clientY; + titleEl.classList.add('long-pressing'); + _lpTimer = setTimeout(() => { + _lpTimer = null; + if (_lpHandled) return; + _lpHandled = true; + titleEl.classList.remove('long-pressing'); + _openSessionActionMenu(S.session, titleEl); + }, _lpDelay); + }, { passive: true }); + titleEl.addEventListener('touchmove', (e) => { + if (!_lpTimer) return; + const touch = e.changedTouches && e.changedTouches[0]; + if (!touch) return; + if (Math.abs(touch.clientX - _lpStartX) > 10 || Math.abs(touch.clientY - _lpStartY) > 10) { + clearTimeout(_lpTimer); _lpTimer = null; + titleEl.classList.remove('long-pressing'); + } + }, { passive: true }); + titleEl.addEventListener('touchend', (e) => { + clearTimeout(_lpTimer); _lpTimer = null; + titleEl.classList.remove('long-pressing'); + if (_lpHandled) { e.preventDefault(); e.stopPropagation(); } + }, { passive: false }); + titleEl.addEventListener('touchcancel', () => { + clearTimeout(_lpTimer); _lpTimer = null; _lpHandled = false; + titleEl.classList.remove('long-pressing'); + }, { passive: true }); + } + } } function _beginSettingsPanelSession() { diff --git a/static/style.css b/static/style.css index 1bed4be8..036d18d2 100644 --- a/static/style.css +++ b/static/style.css @@ -989,6 +989,7 @@ .app-titlebar-title{font-size:12px;font-weight:600;color:var(--text);letter-spacing:-.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:60vw;} .app-titlebar-sub{font-size:10px;color:var(--muted);background:var(--hover-bg);padding:2px 7px;border-radius:4px;font-family:'SF Mono',ui-monospace,monospace;white-space:nowrap;flex-shrink:0;} .app-titlebar-sub[hidden]{display:none;} + .app-titlebar-title-popover{position:fixed;z-index:300;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 12px;font-size:13px;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.18);word-break:break-word;line-height:1.4;max-width:calc(100vw - 16px);pointer-events:auto;} .app-titlebar-hamburger,.app-titlebar-spacer{display:none;width:44px;height:44px;flex-shrink:0;} .app-titlebar-hamburger{-webkit-app-region:no-drag;align-items:center;justify-content:center;background:none;border:none;color:var(--muted);border-radius:8px;cursor:pointer;padding:0;-webkit-tap-highlight-color:transparent;transition:background-color .15s,color .15s;} .app-titlebar-hamburger:hover{background:var(--hover-bg);color:var(--text);} @@ -2356,6 +2357,9 @@ .app-titlebar-hamburger,.app-titlebar-spacer{display:flex;} .app-titlebar-new-chat{display:inline-flex;} .app-titlebar-inner{flex:1 1 auto;} + .app-titlebar-title{max-width:72vw;} + .app-titlebar-sub{font-size:9px;padding:1px 5px;} + .app-titlebar-title.long-pressing{opacity:.6;transition:opacity .1s;} .system-health-panel.insights-card{gap:10px;padding:12px;} .system-health-head{align-items:flex-start;} .system-health-metrics{grid-template-columns:1fr;gap:8px;} diff --git a/tests/test_mobile_titlebar_session_actions.py b/tests/test_mobile_titlebar_session_actions.py new file mode 100644 index 00000000..132fa100 --- /dev/null +++ b/tests/test_mobile_titlebar_session_actions.py @@ -0,0 +1,28 @@ +"""Static regressions for mobile titlebar session title priority (#4520).""" +import re, pathlib, pytest + +ROOT = pathlib.Path(__file__).parent.parent +HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") +PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") +SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def test_titlebar_title_element_exists(): + assert 'id="appTitlebarTitle"' in HTML + assert 'id="appTitlebarSub"' in HTML + + +def test_syncappTitlebar_compact_metadata(): + assert 'subText = String(vis.length)' in PANELS_JS + assert "t('n_messages', vis.length)" not in PANELS_JS + + +def test_long_press_handler_wired(): + assert '_lpTimer' in PANELS_JS + assert 'SESSION_LONG_PRESS_DELAY_MS' in PANELS_JS + assert 'long-pressing' in PANELS_JS + + +def test_session_action_menu_callable_from_titlebar(): + assert '_openSessionActionMenu(S.session, titleEl)' in PANELS_JS