From f8af4ecc6d93d1346720212ea1850c1fc3a925bd Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:14:54 -0400 Subject: [PATCH 1/6] 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 3fb11cf49..25644d02d 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 1bed4be86..036d18d21 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 000000000..132fa1005 --- /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 From dee42a89cbd287f3a3a993154323082c2d3032ab Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:25:48 -0400 Subject: [PATCH 2/6] fix(mobile): address review findings for titlebar touch interactions (#4520) --- static/panels.js | 76 ++++++++++++++++-------------- tests/test_app_titlebar_restore.py | 2 +- 2 files changed, 42 insertions(+), 36 deletions(-) diff --git a/static/panels.js b/static/panels.js index 25644d02d..b1ea8136b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -152,45 +152,51 @@ function syncAppTitlebar() { }; } - // Mobile touch interactions — wired once per element lifetime. - if ('ontouchstart' in window && !titleEl._mobileTouchWired) { - titleEl._mobileTouchWired = true; + // Dismiss stale popover on session/panel switch + const _existingPop = document.querySelector('.app-titlebar-title-popover'); + if (_existingPop) _existingPop.remove(); - // 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); - } + // Mobile touch interactions + if ('ontouchstart' in window) { + // Tap-to-reveal full title popover — wired once per element lifetime + if (!titleEl._mobileTouchWired) { + titleEl._mobileTouchWired = true; + let _titlePopover = null; + const _dismissTitlePopover = () => { + if (_titlePopover) { _titlePopover.remove(); _titlePopover = null; } }; - setTimeout(() => document.addEventListener('click', _outside, true), 0); - }, { passive: true }); + titleEl.addEventListener('click', function _onTitleClick(e) { + if (_renamingAppTitlebar) return; + if (_titlePopover) { + _dismissTitlePopover(); + 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 && + // Long-press → session action menu (re-evaluated each sync so late-arriving sessions attach) + if (!titleEl._mobileLpWired && panel === 'chat' && S && S.session && + !S.session.read_only && !S.session.is_read_only && typeof _openSessionActionMenu === 'function') { + titleEl._mobileLpWired = true; let _lpTimer = null; let _lpHandled = false; let _lpStartX = 0, _lpStartY = 0; diff --git a/tests/test_app_titlebar_restore.py b/tests/test_app_titlebar_restore.py index 155e3761a..f14830647 100644 --- a/tests/test_app_titlebar_restore.py +++ b/tests/test_app_titlebar_restore.py @@ -18,7 +18,7 @@ def test_app_titlebar_returns_to_centered_desktop_layout(): def test_app_titlebar_subtitle_shows_message_count_again(): - assert "subText = t('n_messages', vis.length);" in PANELS_JS + assert "subText = String(vis.length);" in PANELS_JS def test_queue_updates_do_not_hijack_app_titlebar_subtitle(): From 96d3011209d631769552b314585a47734aff847a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 21:35:37 -0400 Subject: [PATCH 3/6] fix(mobile): fix stale popover closure and clean up test imports (#4520) --- static/panels.js | 10 +++++----- tests/test_mobile_titlebar_session_actions.py | 4 +--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/static/panels.js b/static/panels.js index b1ea8136b..af2cecc9b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -154,20 +154,20 @@ function syncAppTitlebar() { // Dismiss stale popover on session/panel switch const _existingPop = document.querySelector('.app-titlebar-title-popover'); - if (_existingPop) _existingPop.remove(); + if (_existingPop) { _existingPop.remove(); titleEl._titlePopover = null; } // Mobile touch interactions if ('ontouchstart' in window) { // Tap-to-reveal full title popover — wired once per element lifetime if (!titleEl._mobileTouchWired) { titleEl._mobileTouchWired = true; - let _titlePopover = null; + titleEl._titlePopover = null; const _dismissTitlePopover = () => { - if (_titlePopover) { _titlePopover.remove(); _titlePopover = null; } + if (titleEl._titlePopover) { titleEl._titlePopover.remove(); titleEl._titlePopover = null; } }; titleEl.addEventListener('click', function _onTitleClick(e) { if (_renamingAppTitlebar) return; - if (_titlePopover) { + if (titleEl._titlePopover) { _dismissTitlePopover(); return; } @@ -181,7 +181,7 @@ function syncAppTitlebar() { 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; + titleEl._titlePopover = pop; const _outside = (ev) => { if (!pop.contains(ev.target) && ev.target !== titleEl) { _dismissTitlePopover(); diff --git a/tests/test_mobile_titlebar_session_actions.py b/tests/test_mobile_titlebar_session_actions.py index 132fa1005..95d447020 100644 --- a/tests/test_mobile_titlebar_session_actions.py +++ b/tests/test_mobile_titlebar_session_actions.py @@ -1,11 +1,9 @@ """Static regressions for mobile titlebar session title priority (#4520).""" -import re, pathlib, pytest +import pathlib 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(): From 79453dcd743c243fc848f7c8ce6e17a293a7cb08 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 22:32:47 -0400 Subject: [PATCH 4/6] fix(mobile): clean up stale popover outside-click listener on forced dismiss (#4520) --- static/panels.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/static/panels.js b/static/panels.js index af2cecc9b..1e470ad85 100644 --- a/static/panels.js +++ b/static/panels.js @@ -154,7 +154,13 @@ function syncAppTitlebar() { // Dismiss stale popover on session/panel switch const _existingPop = document.querySelector('.app-titlebar-title-popover'); - if (_existingPop) { _existingPop.remove(); titleEl._titlePopover = null; } + if (_existingPop) { + _existingPop.remove(); titleEl._titlePopover = null; + if (titleEl._popoverOutsideHandler) { + document.removeEventListener('click', titleEl._popoverOutsideHandler, true); + titleEl._popoverOutsideHandler = null; + } + } // Mobile touch interactions if ('ontouchstart' in window) { @@ -182,10 +188,11 @@ function syncAppTitlebar() { pop.style.left = Math.max(8, rect.left) + 'px'; pop.style.maxWidth = (window.innerWidth - 16) + 'px'; titleEl._titlePopover = pop; - const _outside = (ev) => { + const _outside = titleEl._popoverOutsideHandler = (ev) => { if (!pop.contains(ev.target) && ev.target !== titleEl) { _dismissTitlePopover(); document.removeEventListener('click', _outside, true); + titleEl._popoverOutsideHandler = null; } }; setTimeout(() => document.addEventListener('click', _outside, true), 0); From ddcb3d679a7e7b19a81bbbd73140ea36a34104f9 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 20 Jun 2026 22:44:55 -0400 Subject: [PATCH 5/6] fix(mobile): clean up outside-click listener in tap-to-toggle dismiss path (#4520) --- static/panels.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/panels.js b/static/panels.js index 1e470ad85..2efeb220f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -170,6 +170,10 @@ function syncAppTitlebar() { titleEl._titlePopover = null; const _dismissTitlePopover = () => { if (titleEl._titlePopover) { titleEl._titlePopover.remove(); titleEl._titlePopover = null; } + if (titleEl._popoverOutsideHandler) { + document.removeEventListener('click', titleEl._popoverOutsideHandler, true); + titleEl._popoverOutsideHandler = null; + } }; titleEl.addEventListener('click', function _onTitleClick(e) { if (_renamingAppTitlebar) return; From 66714f04e818a2b088191cf2f2a158c12627bde8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Tue, 30 Jun 2026 19:11:32 +0000 Subject: [PATCH 6/6] docs(changelog): mobile titlebar prioritize session title (#4574) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf54e1d61..f1dc9cbb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **Mobile titlebar now prioritizes the session title, with tap-to-reveal and long-press actions.** On mobile the titlebar gives the session title priority (ellipsized when long) over surrounding chrome; tapping the title reveals the full title in a popover, and a long-press opens the session actions. Listener-leak-safe (tap wired once per element, outside-click handler cleaned up on dismiss/switch; long-press guarded against double-fire, cancelled on touchmove). Read-only sessions are skipped, and desktop is untouched (touch-gated). Thanks @rodboev. (#4574, #4520) + - **Mobile approval dialog and touch targets are easier to tap.** At mobile width the approval card's action buttons now lay out as a clean 2-column grid (Allow once / Allow session / Always allow / Deny) with the "Skip all this session" YOLO button full-width below, every button at a 44px minimum touch target; the titlebar new-chat/reload and the mobile sidebar-close controls are also bumped to 44px. Mobile-scoped (inside the mobile media query) — desktop layout is unchanged. Thanks @disco32r. (#5019) - **`/pet` now works in the WebUI, handing off to the Desktop Companion extension.** Typing `/pet` (and its subcommands) is intercepted client-side and routed to the petdex companion extension with graceful per-stage guidance when the extension isn't installed; the command output is XSS-safe. Thanks @rodboev. (#5168, #4843)