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) diff --git a/static/panels.js b/static/panels.js index 976c3287a..2bb7e2635 100644 --- a/static/panels.js +++ b/static/panels.js @@ -59,7 +59,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 = ''; @@ -153,6 +153,102 @@ function syncAppTitlebar() { inp.select(); }; } + + // Dismiss stale popover on session/panel switch + const _existingPop = document.querySelector('.app-titlebar-title-popover'); + 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) { + // Tap-to-reveal full title popover — wired once per element lifetime + if (!titleEl._mobileTouchWired) { + titleEl._mobileTouchWired = true; + 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; + if (titleEl._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'; + titleEl._titlePopover = pop; + 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); + }, { passive: true }); + } + + // 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; + 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 f3b12d6c4..cd976b278 100644 --- a/static/style.css +++ b/static/style.css @@ -1386,6 +1386,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);} @@ -2824,6 +2825,9 @@ .app-titlebar-hamburger,.app-titlebar-spacer{display:flex;} .app-titlebar-new-chat,.app-titlebar-reload{display:inline-flex;width:44px;height:44px;} .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_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(): diff --git a/tests/test_mobile_titlebar_session_actions.py b/tests/test_mobile_titlebar_session_actions.py new file mode 100644 index 000000000..95d447020 --- /dev/null +++ b/tests/test_mobile_titlebar_session_actions.py @@ -0,0 +1,26 @@ +"""Static regressions for mobile titlebar session title priority (#4520).""" +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") + + +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