diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ae2a564..b03e4353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ ### Fixed +- **Mobile: create forms opened from the sidebar drawer are no longer hidden behind it.** On phones the sidebar is a full-screen drawer over the main view; opening a new cron/skill/workspace/profile form from inside it left the drawer covering the form, so the tap looked like a no-op. The panel re-open on a rail tap now happens synchronously (before the panel's async data load, so a form that closes the drawer isn't re-covered), and the four create-form openers now close the drawer the same way their matching detail views already did. Desktop is unaffected. Thanks @silent-reader-cn. (#6921) + +- **Mobile: creating a conversation from a project chip now shows it instead of leaving the drawer open over it.** The project-chip quick-create ("+") created the new project conversation but never closed the full-screen mobile sidebar drawer, so on phones the new session was created but hidden underneath. The drawer now closes after the session is created and the sidebar repaints (the failure path keeps it open so the error toast stays visible for retry). Desktop is unaffected. Thanks @silent-reader-cn. (#6933) + +- **Mobile: an open extension panel stays active when you reopen the sidebar drawer.** Reopening the mobile drawer treated an active extension panel view as the Chat panel, so the extension's sidebar navigation state was deactivated every time you opened the hamburger/edge drawer — the frame stayed behind the drawer but had no reachable navigation on the phone. The drawer-sync helper now recognizes an active extension view (`showing-x-`), keeps its sidebar view active, and does so without corrupting the host panel state machine. Thanks @rodrigogs. (#6671) + +- **Switching a session between remote (SSH) and local terminal backends no longer reuses a stale cached agent.** The cached-`AIAgent` reuse signature omitted terminal-backend identity, so a session that changed its terminal backend could be served a cached agent still carrying the previous backend's terminal env. The signature now includes the terminal env / SSH host / SSH user, so a backend change forces a fresh agent (matching how a profile change already does). Thanks @webtecnica. (#6175) + - **Historical chat media previews no longer change when a file is overwritten in place.** After the recent switch to revalidating `/api/media` responses, overwriting a file with the same name (e.g. regenerating `chart.png`) silently rewrote every earlier chat preview that referenced it, so you lost the ability to compare the old and new versions. Each settled message now freezes the bytes of its local-file previews into a content-addressed snapshot store and serves those frozen bytes for that message, while new turns still show the current file. The snapshot store is internal (never reachable as a normal media path), bounded (50 MB per file, 2 GB total, oldest-evicted-first), and each stored digest is bound to the exact source path it was captured from, so a snapshot can only ever be served for its own message. Thanks @happy5318. (#6979) - **Multi-container Docker deployments can reach the agent API again.** In the two- and three-container Compose topologies, the agent's gateway API bound to loopback only, so the WebUI (and dashboard) containers — separate hosts on the Docker network — couldn't reach `http://hermes-agent:8642` even with `HERMES_API_URL` and `API_SERVER_KEY` set correctly, breaking cron/Tasks and gateway-backed features. The agent service now sets `API_SERVER_HOST=0.0.0.0` so it's reachable across the Compose network; the host port publish stays `127.0.0.1`-only, so this does not expose the API beyond the Docker network and host loopback. Thanks @bragov4ik. (#6968) diff --git a/api/streaming.py b/api/streaming.py index 1ebad583..425292ff 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -9604,6 +9604,12 @@ def _run_agent_streaming( # field the cached agent silently retains the previous # profile's SOUL.md (and any other profile-scoped context). _profile_home or '', + # Terminal backend identity: sessions switching between + # remote (SSH) and local backends must not reuse a cached + # agent that carries stale terminal env vars (#5937). + _safe_profile_runtime_env.get('TERMINAL_ENV', '') or '', + _safe_profile_runtime_env.get('TERMINAL_SSH_HOST', '') or '', + _safe_profile_runtime_env.get('TERMINAL_SSH_USER', '') or '', ], sort_keys=True) _agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16] diff --git a/static/panels.js b/static/panels.js index 3691357e..487645f9 100644 --- a/static/panels.js +++ b/static/panels.js @@ -353,6 +353,32 @@ function _panelFromCurrentMainView(){ } function _syncMobileSidebarPanelFromMainView(){ + const mainEl=document.querySelector('main.main'); + // Extension panels are intentionally outside MAIN_VIEW_PANELS: the host must + // not try to lazy-load or own their main view. They do, however, publish the + // visible view as `showing-x-` and install a matching sidebar + // `.panel-view[data-panel-token]`. The mobile drawer is reopened through this + // sync helper, so treating that state as Chat deactivated the extension's + // sidebar view every time the operator opened the hamburger or edge drawer. + // The frame remained behind the drawer, but its content had no reachable + // navigation state on the phone. + const extensionClass=mainEl&&Array.from(mainEl.classList) + .find(name=>name.startsWith('showing-x-')); + const extensionToken=extensionClass&&extensionClass.slice('showing-x-'.length); + if(extensionToken){ + const extensionView=Array.from(document.querySelectorAll('.sidebar .panel-view')) + .find(view=>view.dataset.panelToken===extensionToken); + if(extensionView){ + const extensionPanel=`x-${extensionToken}`; + document.querySelectorAll('[data-panel]').forEach(t=>t.classList.toggle('active',t.dataset.panel===extensionPanel)); + document.querySelectorAll('.panel-view').forEach(p=>p.classList.remove('active')); + extensionView.classList.add('active'); + // Do not put an extension token in _currentPanel: switchPanel owns that + // state and only accepts its native panel names. Returning the token keeps + // this helper truthful without corrupting the host state machine. + return extensionPanel; + } + } const panel=_panelFromCurrentMainView(); if(!panel)return _currentPanel||'chat'; const panelEl=$('panel'+panel.charAt(0).toUpperCase()+panel.slice(1)); @@ -395,6 +421,18 @@ async function switchPanel(name, opts = {}) { if (typeof _kanbanStopPolling === 'function') _kanbanStopPolling(); } _currentPanel = nextPanel; + // Mobile drawer visibility: a rail/tab click on a phone should surface the + // panel synchronously, NOT after the panel's async data load. If the re-open + // stayed at the bottom of this function, a form opened from inside the drawer + // (e.g. openWorkspaceCreate closing the drawer) would race the deferred + // re-open and the drawer would win, covering the main-view form. + if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) { + const sidebar = document.querySelector('.sidebar'); + if (sidebar) { + sidebar.classList.remove('mobile-session-page'); + sidebar.classList.add('mobile-panel-drawer', 'mobile-open'); + } + } // Update nav tabs (rail + mobile sidebar-nav share data-panel) document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel)); // Refresh aria-expanded on the newly-active rail button to mirror sidebar state. @@ -427,13 +465,6 @@ async function switchPanel(name, opts = {}) { switchSettingsSection(_currentSettingsSection); loadSettingsPanel(); } - if (opts.fromRailClick && typeof _isDesktopWidth === 'function' && !_isDesktopWidth()) { - const sidebar = document.querySelector('.sidebar'); - if (sidebar) { - sidebar.classList.remove('mobile-session-page'); - sidebar.classList.add('mobile-panel-drawer', 'mobile-open'); - } - } _resyncChatSidebarAfterPanelSwitch(); if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar(); else syncAppTitlebar(); @@ -1545,6 +1576,10 @@ function openCronCreate(){ _cronSkillsCache = null; api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); loadCronProfiles().then(()=>_refreshCronProfileSelect('')).catch(()=>{}); + // Mobile: the cron form lives in the main view, which is covered by the + // full-screen sidebar drawer. Close the drawer so the form is visible (mirror + // openCronDetail's behaviour); no-op on desktop. + _closeMobileSidebarAfterPanelSelection(); } function openCronEdit(job){ @@ -5105,6 +5140,10 @@ function openSkillCreate() { _editingSkillName = null; _skillMode = 'create'; _renderSkillForm({ name: '', category: '', content: '', isEdit: false }); + // Mobile: the new-skill form lives in the main view, which is covered by the + // full-screen sidebar drawer. Close the drawer so the form is visible (mirror + // openSkillDetail's behaviour); no-op on desktop. + _closeMobileSidebarAfterPanelSelection(); } function _renderSkillForm({ name, category, content, isEdit }) { @@ -6153,6 +6192,10 @@ function openWorkspaceCreate(){ _workspacePreFormDetail = _currentWorkspaceDetail ? { ..._currentWorkspaceDetail } : null; _workspaceMode = 'create'; _renderWorkspaceForm({ name:'', path:'', isEdit:false }); + // Mobile: the add-space form lives in the main view, which is covered by the + // full-screen sidebar drawer. Close the drawer so the form is visible (mirror + // openWorkspaceDetail's behaviour); no-op on desktop. + _closeMobileSidebarAfterPanelSelection(); } function editCurrentWorkspace(){ @@ -7256,6 +7299,10 @@ function openProfileCreate(){ _profilePreFormDetail = _currentProfileDetail ? { ..._currentProfileDetail } : null; _profileMode = 'create'; _renderProfileForm(); + // Mobile: the new-profile form lives in the main view, which is covered by the + // full-screen sidebar drawer. Close the drawer so the form is visible (mirror + // openWorkspaceDetail's behaviour); no-op on desktop. + _closeMobileSidebarAfterPanelSelection(); } function _renderProfileForm(){ diff --git a/static/sessions.js b/static/sessions.js index 0c584aec..c5d7acc8 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -7545,6 +7545,12 @@ function _attachProjectQuickCreateButton(chip, project){ // project-assigned session appears deterministically. try{ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); }catch(_){} try{ if(typeof renderSessionList==='function') void renderSessionList({deferWhileInteracting:false}); }catch(_){} + // Mobile: the sidebar is a full-screen drawer over the main view — close + // it after the project conversation is created so the user actually sees + // the new session (mirrors $('btnNewChat').onclick in boot.js and the + // #5409 close in _openSidebarSession). Failure path keeps the drawer open + // so the toast stays visible for retry. + if(typeof closeMobileSidebar==='function') closeMobileSidebar(); }catch(err){ _setActiveProjectFilter(previousProject); if(typeof showToast==='function') showToast('New conversation failed: '+(err&&err.message||err)); diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index 2b476f0d..d8014810 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -685,6 +685,18 @@ def test_mobile_sidebar_open_syncs_panel_from_visible_detail_view(): assert "_currentPanel=panel" in sync_body assert "document.querySelectorAll('[data-panel]')" in sync_body assert "document.querySelectorAll('.panel-view')" in sync_body + assert "showing-x-" in sync_body, ( + "Mobile sidebar sync must recognize an active extension panel instead of treating it as Chat" + ) + assert "data-panel-token" in sync_body, ( + "Mobile sidebar sync must restore the extension's matching sidebar view" + ) + assert "const extensionPanel=`x-${extensionToken}`" in sync_body, ( + "Extension nav buttons use x- tokens and must regain their active state on mobile" + ) + assert "_currentPanel=extensionPanel" not in sync_body, ( + "Extension tokens are not host panels and must not corrupt switchPanel's native state" + ) boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") toggle_body = _js_function_body(boot_js, "toggleMobileSidebar") assert "_syncMobileSidebarPanelFromMainView()" in toggle_body, ( diff --git a/tests/test_project_chip_ui.py b/tests/test_project_chip_ui.py index f1cab42c..24e51684 100644 --- a/tests/test_project_chip_ui.py +++ b/tests/test_project_chip_ui.py @@ -229,3 +229,51 @@ class TestProjectChipLongPressTouch: # touch tuning so the native callout/selection doesn't compete with the gesture assert "touch-action:manipulation" in chip_rule assert "user-select:none" in chip_rule + + +class TestQuickCreateMobileDrawer: + """The project-chip "+" (quick-create) must close the mobile sidebar drawer. + + On phones the sidebar is a full-screen drawer (`.sidebar.mobile-open`, + z-index 200) covering the main chat view. The quick-create button creates + the new project conversation but never closed the drawer, so on mobile the + tap *looked* like a no-op — the session was created, just hidden underneath. + Mirrors `$('btnNewChat').onclick` in boot.js and the #5409 close in + `_openSidebarSession`. + """ + + def _quick_create_block(self): + idx = SESSIONS_JS.find("function _attachProjectQuickCreateButton(") + assert idx != -1, "project quick-create helper not found in sessions.js" + # Function body incl. the full onclick success path (~3K covers it). + return SESSIONS_JS[idx: idx + 3000] + + def test_quick_create_success_path_closes_mobile_drawer(self): + block = self._quick_create_block() + assert "closeMobileSidebar" in block, ( + "project quick-create + must close the mobile sidebar after " + "newSession so the new conversation is visible on phones" + ) + # Guarded call, matching the _openSidebarSession (#5409) convention. + assert "typeof closeMobileSidebar==='function'" in block + + def test_quick_create_close_runs_after_sidebar_repaint(self): + """The close must sit in the success path after the sidebar repaint — + not before newSession (the drawer would reopen over the pending load) + and not in the catch branch (failure should keep the drawer open so the + user can see the toast and retry).""" + block = self._quick_create_block() + render_idx = block.find("renderSessionList({deferWhileInteracting:false})") + close_idx = block.find("closeMobileSidebar") + catch_idx = block.find("catch(err)") + assert render_idx != -1, "sidebar repaint call not found in quick-create handler" + assert close_idx != -1, "closeMobileSidebar not found in quick-create handler" + assert catch_idx != -1, "success-path try/catch not found in quick-create handler" + assert close_idx > render_idx, ( + "closeMobileSidebar must run after the sidebar repaint in the success path" + ) + assert close_idx < catch_idx, ( + "closeMobileSidebar must stay in the success path (try block) — moving it " + "into the catch branch would keep the drawer open over the error toast" + ) +