Release: mobile drawer + terminal-backend cache fixes (#6921 #6933 #6671 #6175) (#6990)

* fix: include terminal backend identity in agent cache signature (#5937) (#6175)

* fix(mobile): preserve extension panels in drawer sync

* fix: close mobile sidebar drawer when opening main-view create forms

The add-space/new-profile/new-skill/new-cron forms render in the main
view, which on phones is covered by the full-screen sidebar drawer
(z-index 200). Opening one from inside the drawer only focused the
input (keyboard popped up) while the form stayed hidden underneath.

- openWorkspaceCreate / openProfileCreate / openSkillCreate /
  openCronCreate now close the drawer after rendering the form,
  mirroring their *_Detail siblings.
- move the fromRailClick drawer re-open in switchPanel() into the
  synchronous section: it used to run after await loadXxxPanel(),
  racing (and re-opening over) a drawer close issued from inside the
  drawer during the panel load window.

Verified in Playwright (390x844): drawer closes and stays closed after
plus-click, form visible with focus on the path input; desktop (1280x800)
unchanged; 83 mobile + 26 workspace tests pass.

* fix: close mobile sidebar after project quick-create plus

The per-project '+' button (_attachProjectQuickCreateButton) created the
new project conversation but never closed the mobile sidebar drawer, so on
phones the tap looked like a no-op: the session was created, just hidden
underneath the full-screen drawer (z-index 200).

Close the drawer in the success path after the sidebar repaint, mirroring
$('btnNewChat').onclick in boot.js and the #5409 close in
_openSidebarSession. Failure path keeps the drawer open so the error toast
stays visible for retry.

Adds static regression tests pinning the guarded close call and its
position after the repaint.

* test: pin quick-create drawer close to the success path (try block)

Greptile review note: the string-presence and ordering assertions alone
would still pass if the guarded closeMobileSidebar call were moved into
the later catch branch, silently reintroducing the mobile drawer bug.

Add an explicit assertion that the close sits before the success-path
catch(err), so a regression that moves it into the failure branch is
caught.

* docs(changelog): stamp batch1-p1 (#6921 #6933 #6671 #6175)

---------

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: Rodrigo Gomes da Silva <rodrigo.smscom@gmail.com>
Co-authored-by: silent-reader-cn <silent-reader-cn@users.noreply.github.com>
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-13 16:41:32 +00:00
1 parent 7d0bc4b6a5
commit e7ac9f09fd
6 files changed
+134 -7

No files matched your search

+8
View File
@@ -5,6 +5,14 @@
### Fixed ### 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-<token>`), 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) - **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) - **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)
+6
View File
@@ -9604,6 +9604,12 @@ def _run_agent_streaming(
# field the cached agent silently retains the previous # field the cached agent silently retains the previous
# profile's SOUL.md (and any other profile-scoped context). # profile's SOUL.md (and any other profile-scoped context).
_profile_home or '', _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) ], sort_keys=True)
_agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16] _agent_sig = _hashlib.sha256(_sig_blob.encode()).hexdigest()[:16]
+54 -7
View File
@@ -353,6 +353,32 @@ function _panelFromCurrentMainView(){
} }
function _syncMobileSidebarPanelFromMainView(){ 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-<token>` 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(); const panel=_panelFromCurrentMainView();
if(!panel)return _currentPanel||'chat'; if(!panel)return _currentPanel||'chat';
const panelEl=$('panel'+panel.charAt(0).toUpperCase()+panel.slice(1)); const panelEl=$('panel'+panel.charAt(0).toUpperCase()+panel.slice(1));
@@ -395,6 +421,18 @@ async function switchPanel(name, opts = {}) {
if (typeof _kanbanStopPolling === 'function') _kanbanStopPolling(); if (typeof _kanbanStopPolling === 'function') _kanbanStopPolling();
} }
_currentPanel = nextPanel; _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) // Update nav tabs (rail + mobile sidebar-nav share data-panel)
document.querySelectorAll('[data-panel]').forEach(t => t.classList.toggle('active', t.dataset.panel === nextPanel)); 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. // Refresh aria-expanded on the newly-active rail button to mirror sidebar state.
@@ -427,13 +465,6 @@ async function switchPanel(name, opts = {}) {
switchSettingsSection(_currentSettingsSection); switchSettingsSection(_currentSettingsSection);
loadSettingsPanel(); 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(); _resyncChatSidebarAfterPanelSwitch();
if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar(); if (nextPanel === 'chat' && typeof syncTopbar === 'function') syncTopbar();
else syncAppTitlebar(); else syncAppTitlebar();
@@ -1545,6 +1576,10 @@ function openCronCreate(){
_cronSkillsCache = null; _cronSkillsCache = null;
api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{}); api('/api/skills').then(d=>{_cronSkillsCache=d.skills||[]; _bindCronSkillPicker();}).catch(()=>{});
loadCronProfiles().then(()=>_refreshCronProfileSelect('')).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){ function openCronEdit(job){
@@ -5105,6 +5140,10 @@ function openSkillCreate() {
_editingSkillName = null; _editingSkillName = null;
_skillMode = 'create'; _skillMode = 'create';
_renderSkillForm({ name: '', category: '', content: '', isEdit: false }); _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 }) { function _renderSkillForm({ name, category, content, isEdit }) {
@@ -6153,6 +6192,10 @@ function openWorkspaceCreate(){
_workspacePreFormDetail = _currentWorkspaceDetail ? { ..._currentWorkspaceDetail } : null; _workspacePreFormDetail = _currentWorkspaceDetail ? { ..._currentWorkspaceDetail } : null;
_workspaceMode = 'create'; _workspaceMode = 'create';
_renderWorkspaceForm({ name:'', path:'', isEdit:false }); _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(){ function editCurrentWorkspace(){
@@ -7256,6 +7299,10 @@ function openProfileCreate(){
_profilePreFormDetail = _currentProfileDetail ? { ..._currentProfileDetail } : null; _profilePreFormDetail = _currentProfileDetail ? { ..._currentProfileDetail } : null;
_profileMode = 'create'; _profileMode = 'create';
_renderProfileForm(); _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(){ function _renderProfileForm(){
+6
View File
@@ -7545,6 +7545,12 @@ function _attachProjectQuickCreateButton(chip, project){
// project-assigned session appears deterministically. // project-assigned session appears deterministically.
try{ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); }catch(_){} try{ if(typeof renderSessionListFromCache==='function') renderSessionListFromCache(); }catch(_){}
try{ if(typeof renderSessionList==='function') void renderSessionList({deferWhileInteracting:false}); }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){ }catch(err){
_setActiveProjectFilter(previousProject); _setActiveProjectFilter(previousProject);
if(typeof showToast==='function') showToast('New conversation failed: '+(err&&err.message||err)); if(typeof showToast==='function') showToast('New conversation failed: '+(err&&err.message||err));
+12
View File
@@ -685,6 +685,18 @@ def test_mobile_sidebar_open_syncs_panel_from_visible_detail_view():
assert "_currentPanel=panel" in sync_body assert "_currentPanel=panel" in sync_body
assert "document.querySelectorAll('[data-panel]')" in sync_body assert "document.querySelectorAll('[data-panel]')" in sync_body
assert "document.querySelectorAll('.panel-view')" 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") boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
toggle_body = _js_function_body(boot_js, "toggleMobileSidebar") toggle_body = _js_function_body(boot_js, "toggleMobileSidebar")
assert "_syncMobileSidebarPanelFromMainView()" in toggle_body, ( assert "_syncMobileSidebarPanelFromMainView()" in toggle_body, (
+48
View File
@@ -229,3 +229,51 @@ class TestProjectChipLongPressTouch:
# touch tuning so the native callout/selection doesn't compete with the gesture # touch tuning so the native callout/selection doesn't compete with the gesture
assert "touch-action:manipulation" in chip_rule assert "touch-action:manipulation" in chip_rule
assert "user-select:none" 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"
)