From d8f0f8f97f9cfb327f2cc1c50c6888143eb4f253 Mon Sep 17 00:00:00 2001 From: Paladin173 <35980893+Paladin173@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:11:54 -0500 Subject: [PATCH 1/2] Fix composer control reorder rebase collision Reapply footer control ordering on current upstream/master while preserving the required situational chip renderer. Persist composer_control_order with backend validation, keep the settings descriptions reorder-aware, and make primary/situational chip renderers participate in same-group drag ordering. Verified with: node --check static/boot.js; node --check static/panels.js; git diff --check; ./scripts/test.sh tests/test_issue4598_composer_control_visibility.py --- api/config.py | 18 +- static/boot.js | 85 ++++++++-- static/i18n.js | 28 ++-- static/index.html | 2 +- static/panels.js | 157 +++++++++++++++++- static/style.css | 2 + ...t_issue4598_composer_control_visibility.py | 154 +++++++++++++++++ 7 files changed, 404 insertions(+), 42 deletions(-) diff --git a/api/config.py b/api/config.py index 19721d49..f8577a8c 100644 --- a/api/config.py +++ b/api/config.py @@ -7847,6 +7847,7 @@ _SETTINGS_DEFAULTS = { "inflight_state_max_json_chars": 1500000, # max serialized recovery snapshot payload before pruning "hidden_tabs": [], # sidebar tab panel names hidden by user (e.g. ["tasks","kanban"]); chat and settings are always visible "tab_order": [], # user-defined sidebar/rail tab order for reorderable tabs; chat/settings stay fixed + "composer_control_order": [], # user-defined composer footer control order; invalid/duplicate keys are ignored "language": "en", # UI locale code; must match a key in static/i18n.js LOCALES "bot_name": os.getenv( "HERMES_WEBUI_BOT_NAME", "Hermes" @@ -7874,6 +7875,9 @@ _SETTINGS_LEGACY_DROP_KEYS = { "activity_feed_expanded_default", "simplified_tool_calling", } +_COMPOSER_CONTROL_ORDER_KEYS = { + key for key in _SETTINGS_DEFAULTS if key.startswith("hide_composer_") +} _SETTINGS_THEME_VALUES = {"light", "dark", "system"} _SETTINGS_SKIN_VALUES = { "default", @@ -8180,10 +8184,10 @@ def save_settings(settings: dict) -> dict: not isinstance(v, str) or not _SETTINGS_LANG_RE.match(v) ): continue - # Validate list-valued sidebar tab settings. Chat/settings stay fixed - # even if a tampered POST tries to persist them, and duplicates are - # collapsed while preserving the first requested order. - if k in {"hidden_tabs", "tab_order"}: + # Validate list-valued ordering settings. Chat/settings stay fixed + # for tabs; composer ordering only accepts known control keys. + # Duplicates are collapsed while preserving the first requested order. + if k in {"hidden_tabs", "tab_order", "composer_control_order"}: if not isinstance(v, list): continue seen = set() @@ -8192,7 +8196,11 @@ def save_settings(settings: dict) -> dict: if not isinstance(s, str): continue s = s.strip() - if not s or s in {"chat", "settings"} or s in seen: + if not s or s in seen: + continue + if k in {"hidden_tabs", "tab_order"} and s in {"chat", "settings"}: + continue + if k == "composer_control_order" and s not in _COMPOSER_CONTROL_ORDER_KEYS: continue seen.add(s) cleaned.append(s) diff --git a/static/boot.js b/static/boot.js index 56efabc4..e40d2858 100644 --- a/static/boot.js +++ b/static/boot.js @@ -2398,25 +2398,25 @@ function applyBotName(){ } const _COMPOSER_CONTROL_TOGGLE_DEFS=[ - {key:'hide_composer_attach',label:'Attach',labelKey:'composer_control_attach',selectors:['#btnAttach']}, - {key:'hide_composer_saved_prompts',label:'Saved prompts',labelKey:'composer_control_saved_prompts',selectors:['#btnSavedPrompts']}, - {key:'hide_composer_mic',label:'Mic',labelKey:'composer_control_mic',selectors:['#btnMic']}, - {key:'hide_composer_profile',label:'Profile',labelKey:'composer_control_profile',selectors:['#profileChipWrap']}, - {key:'hide_composer_workspace',label:'Workspace',labelKey:'composer_control_workspace',selectors:['.composer-ws-wrap','#composerMobileWorkspaceAction']}, - {key:'hide_composer_model',label:'Model',labelKey:'composer_control_model',selectors:['.composer-model-wrap','#composerMobileModelAction']}, - {key:'hide_composer_reasoning',label:'Reasoning',labelKey:'composer_control_reasoning',selectors:['#composerReasoningWrap','#composerMobileReasoningAction']}, - {key:'hide_composer_context',label:'Context',labelKey:'composer_control_context',selectors:['#ctxIndicatorWrap','#composerMobileContextAction']}, + {key:'hide_composer_attach',label:'Attach',labelKey:'composer_control_attach',selectors:['#btnAttach'],orderSelector:'#btnAttach',orderGroup:'left'}, + {key:'hide_composer_saved_prompts',label:'Saved prompts',labelKey:'composer_control_saved_prompts',selectors:['#btnSavedPrompts'],orderSelector:'#btnSavedPrompts',orderGroup:'left'}, + {key:'hide_composer_mic',label:'Mic',labelKey:'composer_control_mic',selectors:['#btnMic'],orderSelector:'#btnMic',orderGroup:'left'}, + {key:'hide_composer_profile',label:'Profile',labelKey:'composer_control_profile',selectors:['#profileChipWrap'],orderSelector:'#profileChipWrap',orderGroup:'left'}, + {key:'hide_composer_workspace',label:'Workspace',labelKey:'composer_control_workspace',selectors:['.composer-ws-wrap','#composerMobileWorkspaceAction'],orderSelector:'.composer-ws-wrap',orderGroup:'left'}, + {key:'hide_composer_model',label:'Model',labelKey:'composer_control_model',selectors:['.composer-model-wrap','#composerMobileModelAction'],orderSelector:'.composer-model-wrap',orderGroup:'left'}, + {key:'hide_composer_reasoning',label:'Reasoning',labelKey:'composer_control_reasoning',selectors:['#composerReasoningWrap','#composerMobileReasoningAction'],orderSelector:'#composerReasoningWrap',orderGroup:'left'}, + {key:'hide_composer_context',label:'Context',labelKey:'composer_control_context',selectors:['#ctxIndicatorWrap','#composerMobileContextAction'],orderSelector:'#ctxIndicatorWrap',orderGroup:'right'}, ]; window._COMPOSER_CONTROL_TOGGLE_DEFS=_COMPOSER_CONTROL_TOGGLE_DEFS; const _COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS=[ - {key:'hide_composer_voice_mode',label:'Voice mode',labelKey:'composer_control_voice_mode',selectors:['#btnVoiceMode']}, - {key:'hide_composer_yolo',label:'YOLO',labelKey:'composer_control_yolo',selectors:['#yoloPill']}, - {key:'hide_composer_bg_badge',label:'Background badge',labelKey:'composer_control_bg_badge',selectors:['#bgBadge']}, - {key:'hide_composer_mobile_config',label:'Mobile config',labelKey:'composer_control_mobile_config',selectors:['#composerMobileConfigBtn']}, - {key:'hide_composer_quota_chip',label:'Quota chip',labelKey:'composer_control_quota_chip',selectors:['#providerQuotaChip','#composerMobileQuotaAction']}, - {key:'hide_composer_toolsets',label:'Toolsets',labelKey:'composer_control_toolsets',selectors:['#composerToolsetsWrap']}, - {key:'hide_composer_status',label:'Status',labelKey:'composer_control_status',selectors:['#composerStatus']}, + {key:'hide_composer_voice_mode',label:'Voice mode',labelKey:'composer_control_voice_mode',selectors:['#btnVoiceMode'],orderSelector:'#btnVoiceMode',orderGroup:'left'}, + {key:'hide_composer_yolo',label:'YOLO',labelKey:'composer_control_yolo',selectors:['#yoloPill'],orderSelector:'#yoloPill',orderGroup:'left'}, + {key:'hide_composer_bg_badge',label:'Background badge',labelKey:'composer_control_bg_badge',selectors:['#bgBadge'],orderSelector:'#bgBadge',orderGroup:'right'}, + {key:'hide_composer_mobile_config',label:'Mobile config',labelKey:'composer_control_mobile_config',selectors:['#composerMobileConfigBtn'],orderSelector:'#composerMobileConfigBtn',orderGroup:'left'}, + {key:'hide_composer_quota_chip',label:'Quota chip',labelKey:'composer_control_quota_chip',selectors:['#providerQuotaChip','#composerMobileQuotaAction'],orderSelector:'#providerQuotaChip',orderGroup:'left'}, + {key:'hide_composer_toolsets',label:'Toolsets',labelKey:'composer_control_toolsets',selectors:['#composerToolsetsWrap'],orderSelector:'#composerToolsetsWrap',orderGroup:'left'}, + {key:'hide_composer_status',label:'Status',labelKey:'composer_control_status',selectors:['#composerStatus'],orderSelector:'#composerStatus',orderGroup:'right'}, ]; window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS=_COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS; @@ -2424,6 +2424,57 @@ function _allComposerControlToggleDefs(){ return _COMPOSER_CONTROL_TOGGLE_DEFS.concat(_COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS); } +function _sanitizeComposerControlOrder(order){ + if(!Array.isArray(order)) return []; + const allowed=new Set(_allComposerControlToggleDefs().map(def=>def.key)); + const out=[]; + order.forEach(key=>{ + if(typeof key!=='string') return; + key=key.trim(); + if(!key||!allowed.has(key)||out.includes(key)) return; + out.push(key); + }); + return out; +} +window._sanitizeComposerControlOrder=_sanitizeComposerControlOrder; + +function _orderedComposerControlDefs(order){ + const defs=_allComposerControlToggleDefs(); + const byKey=new Map(defs.map(def=>[def.key,def])); + const out=[]; + _sanitizeComposerControlOrder(Array.isArray(order)?order:window._composerControlOrder).forEach(key=>{ + if(byKey.has(key)) out.push(byKey.get(key)); + }); + defs.forEach(def=>{if(!out.includes(def)) out.push(def);}); + return out; +} +window._orderedComposerControlDefs=_orderedComposerControlDefs; + +function _applyComposerControlOrder(order){ + window._composerControlOrder=_sanitizeComposerControlOrder(order); + const grouped=new Map(); + _orderedComposerControlDefs(window._composerControlOrder).forEach(def=>{ + const node=document.querySelector(def.orderSelector||def.selectors&&def.selectors[0]); + if(!node||!node.parentNode) return; + const parent=node.parentNode; + if(!grouped.has(parent)) grouped.set(parent,[]); + grouped.get(parent).push(node); + }); + grouped.forEach((nodes,parent)=>{ + if(!nodes.length) return; + const marker=document.createComment('composer-control-order'); + parent.insertBefore(marker,nodes[0]); + let ref=marker; + nodes.forEach(node=>{ + parent.insertBefore(node,ref.nextSibling); + ref=node; + }); + marker.remove(); + }); + if(typeof _fitComposerFooter==='function') _fitComposerFooter(); +} +window._applyComposerControlOrder=_applyComposerControlOrder; + function _composerControlVisibilityFromSettings(settings){ const next={}; for(const def of _allComposerControlToggleDefs()){ @@ -2535,6 +2586,8 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; window._largeTextPasteAsAttachment=s.large_text_paste_as_attachment!==false; window._projectQuickCreate=!!s.project_quick_create_buttons; window._composerControlVisibility=_composerControlVisibilityFromSettings(s); + window._composerControlOrder=_sanitizeComposerControlOrder(s.composer_control_order); + _applyComposerControlOrder(window._composerControlOrder); window._showTitlebarProfile=!!s.show_titlebar_profile; _applyTitlebarProfileVisibility(); window._botName=s.bot_name||'Hermes'; @@ -2657,6 +2710,8 @@ window._applyTitlebarProfileVisibility=_applyTitlebarProfileVisibility; window._sessionEndlessScrollEnabled=false; window._autoScrollFollow=true; window._composerControlVisibility=_composerControlVisibilityFromSettings(null); + window._composerControlOrder=[]; + _applyComposerControlOrder(window._composerControlOrder); window._botName='Hermes'; _bootSettings={check_for_updates:false}; if(typeof setLocale==='function'){ diff --git a/static/i18n.js b/static/i18n.js index 5f0f5693..a8c54f83 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -647,7 +647,7 @@ const LOCALES = { settings_label_tab_visibility: 'Sidebar tabs', settings_desc_tab_visibility: 'Choose which tabs appear in the sidebar and rail. Drag chips to reorder them. Chat and Settings are always visible.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -2310,7 +2310,7 @@ const LOCALES = { settings_label_tab_visibility: 'Schede della barra laterale', settings_desc_tab_visibility: 'Scegli quali schede mostrare nella barra laterale e nel rail. Chat e Impostazioni sono sempre visibili.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -3964,7 +3964,7 @@ const LOCALES = { settings_label_tab_visibility: 'サイドバータブ', settings_desc_tab_visibility: 'サイドバーとレールに表示するタブを選択します。チャットと設定は常に表示されます。', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -6345,7 +6345,7 @@ const LOCALES = { settings_label_tab_visibility: 'Вкладки боковой панели', settings_desc_tab_visibility: 'Выберите, какие вкладки отображаются на боковой панели и в рейле. Чат и настройки всегда видны.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -7924,7 +7924,7 @@ const LOCALES = { settings_label_tab_visibility: 'Pestañas de la barra lateral', settings_desc_tab_visibility: 'Elige qué pestañas aparecen en la barra lateral y el rail. Chat y Configuración siempre están visibles.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -9184,7 +9184,7 @@ const LOCALES = { settings_label_tab_visibility: 'Seitenleiste-Tabs', settings_desc_tab_visibility: 'Wähle, welche Tabs in der Seitenleiste und im Rail angezeigt werden. Ziehe die Chips, um die Reihenfolge zu ändern. Chat und Einstellungen sind immer sichtbar.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -11117,7 +11117,7 @@ const LOCALES = { settings_label_tab_visibility: '侧边栏标签', settings_desc_tab_visibility: '选择在侧边栏和导航栏中显示哪些标签。聊天和设置始终可见。', settings_label_composer_controls: '聊天底部控件', - settings_desc_composer_controls: '切换显示在聊天底部的控件。不支持重新排序。', + settings_desc_composer_controls: '切换显示在聊天底部的控件。拖动芯片可在各自的页脚分组内重新排序。', settings_label_composer_situational_controls: '情境化底部控件', settings_desc_composer_situational_controls: '这些控件并非始终显示,具体取决于视口、模式或运行时状态。如有需要,你仍可将其关闭。', composer_control_attach: '附加', @@ -11983,7 +11983,7 @@ const LOCALES = { settings_label_tab_visibility: '側邊欄標籤', settings_desc_tab_visibility: '選擇在側邊欄和導覽列中顯示哪些標籤。聊天和設定始終可見。', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -13534,7 +13534,7 @@ const LOCALES = { settings_label_tab_visibility: 'Abas da barra lateral', settings_desc_tab_visibility: 'Escolha quais abas aparecem na barra lateral e no rail. Chat e Configurações estão sempre visíveis.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -15087,7 +15087,7 @@ const LOCALES = { settings_label_tab_visibility: '사이드바 탭', settings_desc_tab_visibility: '사이드바와 레일에 표시할 탭을 선택하세요. 채팅과 설정은 항상 표시됩니다.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -16753,7 +16753,7 @@ const LOCALES = { settings_label_tab_visibility: 'Onglets de la barre latérale', settings_desc_tab_visibility: 'Choisir quels onglets apparaissent dans la barre latérale et le rail. Chat et Paramètres sont toujours visibles. Faites glisser les puces pour les réorganiser.', settings_label_composer_controls: 'Contrôles du pied de page du chat', - settings_desc_composer_controls: 'Activer/désactiver les contrôles affichés dans le pied de page du chat. La réorganisation n\'est pas prise en charge.', + settings_desc_composer_controls: 'Activer/désactiver les contrôles affichés dans le pied de page du chat. Faites glisser les puces pour réorganiser chaque groupe du pied de page.', settings_label_composer_situational_controls: 'Contrôles contextuels du pied de page', settings_desc_composer_situational_controls: 'Ces contrôles n\'apparaissent pas toujours, selon la zone d\'affichage, le mode ou l\'état d\'exécution. Vous pouvez toujours les désactiver si nécessaire.', composer_control_attach: 'Joindre', @@ -18375,7 +18375,7 @@ const LOCALES = { settings_label_tab_visibility: 'Kenar çubuğu sekmeleri', settings_desc_tab_visibility: 'Kenar çubuğunda ve rayda hangi sekmelerin görüneceğini seçin. Sohbet ve Ayarlar her zaman görünür durumdadır.', settings_label_composer_controls: 'Chat footer controls', - settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Reordering is not supported.', + settings_desc_composer_controls: 'Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.', settings_label_composer_situational_controls: 'Situational footer controls', settings_desc_composer_situational_controls: 'These controls do not always appear, depending on viewport, mode, or runtime state. You can still toggle them off if needed.', composer_control_attach: 'Attach', @@ -20043,7 +20043,7 @@ const LOCALES = { settings_label_tab_visibility: 'Karty paska bocznego', settings_desc_tab_visibility: 'Wybierz, które karty pojawiają się na pasku bocznym i szynie. Przeciągnij elementy, aby zmienić ich kolejność. Czat i Ustawienia są zawsze widoczne.', settings_label_composer_controls: 'Kontrolki stopki czatu', - settings_desc_composer_controls: 'Przełączaj kontrolki wyświetlane w stopce czatu. Zmiana kolejności nie jest obsługiwana.', + settings_desc_composer_controls: 'Przełączaj kontrolki wyświetlane w stopce czatu. Przeciągaj etykiety, aby zmieniać kolejność w każdej grupie stopki.', settings_label_composer_situational_controls: 'Sytuacyjne kontrolki stopki', settings_desc_composer_situational_controls: 'Te kontrolki nie zawsze się pojawiają, w zależności od obszaru roboczego, trybu lub stanu środowiska uruchomieniowego. Nadal możesz je wyłączyć, jeśli to konieczne.', composer_control_attach: 'Załącz', @@ -21599,7 +21599,7 @@ const LOCALES = { settings_label_tab_visibility: 'Tab sidebar', settings_desc_tab_visibility: 'Chọn tab nào xuất hiện trong sidebar và thanh rail. Kéo các chip để đổi thứ tự. Chat và Settings luôn hiển thị.', settings_label_composer_controls: 'Điều khiển chân khung chat', - settings_desc_composer_controls: 'Bật/tắt các điều khiển hiển thị ở chân khung chat. Không hỗ trợ đổi thứ tự.', + settings_desc_composer_controls: 'Bật/tắt các điều khiển hiển thị ở chân khung chat. Kéo các chip để đổi thứ tự trong từng nhóm chân khung.', settings_label_composer_situational_controls: 'Điều khiển chân khung theo ngữ cảnh', settings_desc_composer_situational_controls: 'Các điều khiển này không phải lúc nào cũng xuất hiện, tùy theo kích thước màn hình, chế độ hoặc trạng thái chạy. Bạn vẫn có thể tắt chúng nếu cần.', composer_control_attach: 'Đính kèm', diff --git a/static/index.html b/static/index.html index fab84cd3..8a62f4da 100644 --- a/static/index.html +++ b/static/index.html @@ -1101,7 +1101,7 @@
-
Toggle which controls are shown in the chat footer. Reordering is not supported.
+
Toggle which controls are shown in the chat footer. Drag chips to reorder each footer group.
diff --git a/static/panels.js b/static/panels.js index 0a4141ec..acf8f80d 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5865,6 +5865,10 @@ function _refreshProfileSwitchBackground(gen){ if (typeof _applyTabOrder === 'function') _applyTabOrder(order); if (typeof _applyTabVisibility === 'function') _applyTabVisibility(hidden); _ensureComposerControlVisibilityState(s||{}); + if(Array.isArray(s&&s.composer_control_order)){ + const nextOrder=_setComposerControlOrder(s.composer_control_order); + if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(nextOrder); + } _renderComposerControlChips(); _renderComposerSituationalControlChips(); if(typeof _applyComposerFooterVisibilitySettings==='function') _applyComposerFooterVisibilitySettings(); @@ -6708,7 +6712,10 @@ let _settingsPreferencesAutosaveRetryPayload = null; const _ALWAYS_VISIBLE_TABS = new Set(['chat','settings']); const _HIDDEN_TABS_LS_KEY = 'hermes-webui-hidden-tabs'; const _TAB_ORDER_LS_KEY = 'hermes-webui-tab-order'; +const _COMPOSER_CONTROL_ORDER_LS_KEY = 'hermes-webui-composer-control-order'; let _tabVisibilityDragSuppressUntil = 0; +let _composerControlDragSuppressUntil = 0; +let _composerControlDraggingKey = ''; function _sanitizeTabPanelList(panels){ if(!Array.isArray(panels)) return []; @@ -6940,11 +6947,123 @@ function _ensureComposerControlVisibilityState(settings){ Object.assign(window._composerControlVisibility, fromSettings); } -function _composerControlVisibilityPayload(){ - const payload={}; +function _composerControlDefsForSettings(){ const baseDefs=Array.isArray(window._COMPOSER_CONTROL_TOGGLE_DEFS)?window._COMPOSER_CONTROL_TOGGLE_DEFS:[]; const situationalDefs=Array.isArray(window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS)?window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS:[]; - const defs=baseDefs.concat(situationalDefs); + return baseDefs.concat(situationalDefs); +} + +function _getComposerControlOrder(){ + if(Array.isArray(window._composerControlOrder)){ + return typeof window._sanitizeComposerControlOrder==='function' + ? window._sanitizeComposerControlOrder(window._composerControlOrder) + : window._composerControlOrder.slice(); + } + try{ + const raw=localStorage.getItem(_COMPOSER_CONTROL_ORDER_LS_KEY); + if(raw){ + const parsed=JSON.parse(raw); + if(typeof window._sanitizeComposerControlOrder==='function') return window._sanitizeComposerControlOrder(parsed); + if(Array.isArray(parsed)) return parsed.filter(key=>typeof key==='string'); + } + }catch(e){} + return []; +} + +function _setComposerControlOrder(order){ + const sanitized=typeof window._sanitizeComposerControlOrder==='function' + ? window._sanitizeComposerControlOrder(order) + : (Array.isArray(order)?order.filter(key=>typeof key==='string') : []); + window._composerControlOrder=sanitized; + try{localStorage.setItem(_COMPOSER_CONTROL_ORDER_LS_KEY,JSON.stringify(sanitized));}catch(e){} + return sanitized; +} + +function _orderedComposerControlDefsForSettings(defs){ + defs=Array.isArray(defs)?defs:[]; + const byKey=new Map(defs.map(def=>[def.key,def])); + const out=[]; + _getComposerControlOrder().forEach(function(key){ + if(byKey.has(key)) out.push(byKey.get(key)); + }); + defs.forEach(function(def){if(out.indexOf(def)===-1) out.push(def);}); + return out; +} + +function _composerControlOrderGroupKey(key){ + const def=_composerControlDefsForSettings().find(item=>item&&item.key===key); + return def&&def.orderGroup?def.orderGroup:''; +} + +function _composerControlDropAllowed(sourceKey,targetKey){ + if(!sourceKey||!targetKey||sourceKey===targetKey) return false; + const sourceGroup=_composerControlOrderGroupKey(sourceKey); + const targetGroup=_composerControlOrderGroupKey(targetKey); + return !!sourceGroup&&sourceGroup===targetGroup; +} + +function _clearComposerControlDragOver(){ + document.querySelectorAll('[data-composer-control-key].drag-over').forEach(function(el){el.classList.remove('drag-over');}); +} + +function _moveComposerControlOrderKey(sourceKey,targetKey){ + if(!_composerControlDropAllowed(sourceKey,targetKey)) return false; + const order=_orderedComposerControlDefsForSettings(_composerControlDefsForSettings()).map(def=>def.key); + const from=order.indexOf(sourceKey); + const to=order.indexOf(targetKey); + if(from===-1||to===-1) return false; + order.splice(from,1); + order.splice(to,0,sourceKey); + const next=_setComposerControlOrder(order); + if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(next); + _renderComposerControlChips(); + _renderComposerSituationalControlChips(); + _scheduleAppearanceAutosave(); + return true; +} + +function _handleComposerControlChipDrop(e,targetKey){ + if(e){e.preventDefault();e.stopPropagation();} + _clearComposerControlDragOver(); + const sourceKey=e&&e.dataTransfer?e.dataTransfer.getData('text/plain'):_composerControlDraggingKey; + if(_moveComposerControlOrderKey(sourceKey,targetKey)) _composerControlDragSuppressUntil=Date.now()+250; + _composerControlDraggingKey=''; +} + +function _wireComposerControlChipDrag(chip,key){ + if(!chip)return; + chip.setAttribute('data-composer-control-key',key); + chip.setAttribute('draggable','true'); + chip.addEventListener('dragstart',function(e){ + _composerControlDraggingKey=key; + chip.classList.add('dragging'); + if(e.dataTransfer){ + e.dataTransfer.effectAllowed='move'; + e.dataTransfer.setData('text/plain',key); + } + }); + chip.addEventListener('dragend',function(){ + chip.classList.remove('dragging'); + _clearComposerControlDragOver(); + _composerControlDraggingKey=''; + }); + chip.addEventListener('dragover',function(e){ + const sourceKey=_composerControlDraggingKey; + if(!_composerControlDropAllowed(sourceKey,key)){ + if(e.dataTransfer)e.dataTransfer.dropEffect='none'; + return; + } + e.preventDefault(); + chip.classList.add('drag-over'); + if(e.dataTransfer)e.dataTransfer.dropEffect='move'; + }); + chip.addEventListener('dragleave',function(){chip.classList.remove('drag-over');}); + chip.addEventListener('drop',function(e){_handleComposerControlChipDrop(e,key);}); +} + +function _composerControlVisibilityPayload(){ + const payload={}; + const defs=_composerControlDefsForSettings(); const state=window._composerControlVisibility||{}; defs.forEach(function(def){payload[def.key]=!!state[def.key];}); return payload; @@ -6974,7 +7093,7 @@ function _renderComposerControlChips(){ const defs=Array.isArray(window._COMPOSER_CONTROL_TOGGLE_DEFS)?window._COMPOSER_CONTROL_TOGGLE_DEFS:[]; const state=window._composerControlVisibility||{}; container.innerHTML=''; - defs.forEach(function(def){ + _orderedComposerControlDefsForSettings(defs).forEach(function(def){ const chip=document.createElement('button'); chip.type='button'; chip.className='tab-visibility-chip'; @@ -6983,7 +7102,8 @@ function _renderComposerControlChips(){ chip.textContent=_composerControlChipLabel(def); chip.setAttribute('role','switch'); chip.setAttribute('aria-checked',hidden?'false':'true'); - chip.onclick=function(){_toggleComposerControlChip(def.key);}; + chip.onclick=function(){if(Date.now()<_composerControlDragSuppressUntil)return;_toggleComposerControlChip(def.key);}; + _wireComposerControlChipDrag(chip,def.key); container.appendChild(chip); }); } @@ -6994,7 +7114,7 @@ function _renderComposerSituationalControlChips(){ const defs=Array.isArray(window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS)?window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS:[]; const state=window._composerControlVisibility||{}; container.innerHTML=''; - defs.forEach(function(def){ + _orderedComposerControlDefsForSettings(defs).forEach(function(def){ const chip=document.createElement('button'); chip.type='button'; chip.className='tab-visibility-chip'; @@ -7003,7 +7123,8 @@ function _renderComposerSituationalControlChips(){ chip.textContent=_composerControlChipLabel(def); chip.setAttribute('role','switch'); chip.setAttribute('aria-checked',hidden?'false':'true'); - chip.onclick=function(){_toggleComposerControlChip(def.key);}; + chip.onclick=function(){if(Date.now()<_composerControlDragSuppressUntil)return;_toggleComposerControlChip(def.key);}; + _wireComposerControlChipDrag(chip,def.key); container.appendChild(chip); }); } @@ -7527,6 +7648,7 @@ function _appearancePayloadFromUi(){ worklog_details_expanded_default: worklogDetailsExpanded, activity_feed_expanded_default: worklogDetailsExpanded, ..._composerControlVisibilityPayload(), + composer_control_order: _getComposerControlOrder(), hidden_tabs: _getHiddenTabs(), tab_order: _getTabOrder(), }; @@ -7635,6 +7757,10 @@ async function _autosaveAppearanceSettings(payload){ } if(saved){ _ensureComposerControlVisibilityState(saved); + if(Array.isArray(saved.composer_control_order)){ + const nextOrder=_setComposerControlOrder(saved.composer_control_order); + if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(nextOrder); + } _renderComposerControlChips(); _renderComposerSituationalControlChips(); if(typeof _applyComposerFooterVisibilitySettings==='function') _applyComposerFooterVisibilitySettings(); @@ -8025,6 +8151,10 @@ async function loadSettingsPanel(){ }; } _ensureComposerControlVisibilityState(settings); + if(Array.isArray(settings.composer_control_order)){ + const composerOrder=_setComposerControlOrder(settings.composer_control_order); + if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(composerOrder); + } _renderComposerControlChips(); _renderComposerSituationalControlChips(); if(typeof _applyComposerFooterVisibilitySettings==='function') _applyComposerFooterVisibilitySettings(); @@ -10430,6 +10560,17 @@ function _applySavedSettingsUi(saved, body, opts){ else if(typeof _applyBusyComposerPlaceholder==='function') _applyBusyComposerPlaceholder(); if(typeof setLocale==='function') setLocale(language); if(typeof applyLocaleToDOM==='function') applyLocaleToDOM(); + _ensureComposerControlVisibilityState(saved||body||{}); + const composerOrderSource=(saved&&Array.isArray(saved.composer_control_order)) + ? saved.composer_control_order + : (Array.isArray(body.composer_control_order)?body.composer_control_order:null); + if(composerOrderSource){ + const composerOrder=_setComposerControlOrder(composerOrderSource); + if(typeof window._applyComposerControlOrder==='function') window._applyComposerControlOrder(composerOrder); + } + _renderComposerControlChips(); + _renderComposerSituationalControlChips(); + if(typeof _applyComposerFooterVisibilitySettings==='function') _applyComposerFooterVisibilitySettings(); const maxTokensField=$('settingsMaxTokens'); if(maxTokensField){ const savedRawMaxTokens=saved&&saved.max_tokens; @@ -10994,6 +11135,8 @@ async function saveSettings(andClose){ body.large_text_paste_as_attachment=!!($('settingsLargeTextPasteAsAttachment')||{}).checked; body.project_quick_create_buttons=!!($('settingsProjectQuickCreate')||{}).checked; Object.assign(body,_structuredCodeViewFromUi()); + Object.assign(body,_composerControlVisibilityPayload()); + body.composer_control_order=_getComposerControlOrder(); body.language=language; body.show_token_usage=showTokenUsage; const maxTokensField=$('settingsMaxTokens'); diff --git a/static/style.css b/static/style.css index 0c36f861..c9cf00b3 100644 --- a/static/style.css +++ b/static/style.css @@ -4885,6 +4885,8 @@ main.main > #mainPlugin{display:none;} .tab-visibility-chip:focus-visible{outline:2px solid var(--link,var(--accent));outline-offset:2px;} .tab-visibility-chip.chip-off{background:transparent;color:var(--muted);border-color:var(--border);font-weight:400;} .tab-visibility-chip.chip-off:hover{border-color:var(--muted);color:var(--text);} +#composerControlsChips .tab-visibility-chip, +#composerSituationalControlsChips .tab-visibility-chip{touch-action:none;-webkit-user-select:none;-webkit-touch-callout:none;} .composer-control-hidden{display:none!important;} /* Selects & text/password inputs — uniform. Uses !important to win diff --git a/tests/test_issue4598_composer_control_visibility.py b/tests/test_issue4598_composer_control_visibility.py index b242d4ef..63cc3829 100644 --- a/tests/test_issue4598_composer_control_visibility.py +++ b/tests/test_issue4598_composer_control_visibility.py @@ -7,14 +7,21 @@ toggle is wired end-to-end (config boolean key -> boot.js definition + read-back key exists across all locale blocks, so a future refactor can't silently orphan a control or break locale parity. """ +import json +import shutil +import subprocess +import textwrap from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") # The 15 composer-control visibility flags this feature ships. HIDE_KEYS = [ @@ -59,6 +66,22 @@ I18N_KEYS = [ ] +def _extract_function(source: str, name: str) -> str: + marker = f"function {name}(" + start = source.index(marker) + brace = source.index("{", start) + depth = 0 + for idx in range(brace, len(source)): + char = source[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return source[start : idx + 1] + raise AssertionError(f"Could not extract {name}") + + def test_all_hide_flags_registered_as_boolean_settings_keys(): """Every toggle must be in config.py's boolean-keys set so it persists and round-trips through save/load.""" @@ -83,6 +106,38 @@ def test_footer_control_chips_rendered_in_panels(): assert "_applyComposerFooterVisibilitySettings" in PANELS_JS +def test_composer_control_order_frontend_contracts(): + """Footer reorder must coexist with the now-required situational chips.""" + assert "composer_control_order" in CONFIG_PY + assert "composer_control_order" in BOOT_JS + assert "composer_control_order" in PANELS_JS + assert "_sanitizeComposerControlOrder" in BOOT_JS + assert "_orderedComposerControlDefs" in BOOT_JS + assert "_applyComposerControlOrder" in BOOT_JS + assert "orderSelector" in BOOT_JS + assert "orderGroup" in BOOT_JS + assert "_COMPOSER_CONTROL_ORDER_LS_KEY" in PANELS_JS + assert "_orderedComposerControlDefsForSettings" in PANELS_JS + assert "_wireComposerControlChipDrag" in PANELS_JS + assert "_moveComposerControlOrderKey" in PANELS_JS + assert "_composerControlDropAllowed" in PANELS_JS + assert "_renderComposerSituationalControlChips" in PANELS_JS + assert "composerSituationalControlsChips" in PANELS_JS + assert "#composerControlsChips .tab-visibility-chip" in STYLE_CSS + assert "#composerSituationalControlsChips .tab-visibility-chip" in STYLE_CSS + + render_primary = _extract_function(PANELS_JS, "_renderComposerControlChips") + render_situational = _extract_function(PANELS_JS, "_renderComposerSituationalControlChips") + for body in (render_primary, render_situational): + assert "_orderedComposerControlDefsForSettings" in body + assert "_wireComposerControlChipDrag" in body + + move_body = _extract_function(PANELS_JS, "_moveComposerControlOrderKey") + assert "_renderComposerControlChips()" in move_body + assert "_renderComposerSituationalControlChips()" in move_body + assert "_scheduleAppearanceAutosave()" in move_body + + def test_new_i18n_keys_exist_across_all_locale_blocks(): """Every new i18n key must appear in all 13 locale blocks (strict locale parity), not just `en` — otherwise the locale-coverage suite goes red.""" @@ -93,3 +148,102 @@ def test_new_i18n_keys_exist_across_all_locale_blocks(): f"{key} appears {count}x in i18n.js — expected >=13 (one per locale " f"block) for strict locale parity" ) + + assert "Drag chips to reorder each footer group." in INDEX_HTML + assert "Reordering is not supported." not in INDEX_HTML + assert "Reordering is not supported." not in I18N_JS + assert "不支持重新排序" not in I18N_JS + + +def test_composer_control_order_is_validated_and_deduped(): + """The backend persists only known composer control order keys.""" + import api.config as config + + original_settings_file = config.SETTINGS_FILE + test_state_dir = ROOT / ".tmp-test-issue4598" + try: + config.SETTINGS_FILE = test_state_dir / "settings.json" + test_state_dir.mkdir(exist_ok=True) + saved = config.save_settings( + { + "composer_control_order": [ + "hide_composer_model", + "bogus", + "hide_composer_attach", + "hide_composer_model", + 42, + "hide_composer_context", + ] + } + ) + assert saved["composer_control_order"] == [ + "hide_composer_model", + "hide_composer_attach", + "hide_composer_context", + ] + persisted = json.loads(config.SETTINGS_FILE.read_text(encoding="utf-8")) + assert persisted["composer_control_order"] == saved["composer_control_order"] + finally: + config.SETTINGS_FILE = original_settings_file + shutil.rmtree(test_state_dir, ignore_errors=True) + + +def test_composer_control_order_move_updates_both_chip_groups(): + """A same-footer-group drag saves order and re-renders both chip groups.""" + node = shutil.which("node") + if not node: + pytest.skip("node is required for the frontend behavior harness") + + helpers = "\n".join( + _extract_function(PANELS_JS, name) + for name in [ + "_composerControlDefsForSettings", + "_getComposerControlOrder", + "_setComposerControlOrder", + "_orderedComposerControlDefsForSettings", + "_composerControlOrderGroupKey", + "_composerControlDropAllowed", + "_moveComposerControlOrderKey", + ] + ) + script = textwrap.dedent( + f""" + global.window = {{}}; + global.localStorage = {{ + _items: {{}}, + getItem(key) {{ return this._items[key] || null; }}, + setItem(key, value) {{ this._items[key] = String(value); }}, + }}; + const _COMPOSER_CONTROL_ORDER_LS_KEY = 'hermes-webui-composer-control-order'; + window._COMPOSER_CONTROL_TOGGLE_DEFS = [ + {{ key: 'hide_composer_attach', orderGroup: 'left' }}, + {{ key: 'hide_composer_context', orderGroup: 'right' }}, + ]; + window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS = [ + {{ key: 'hide_composer_voice_mode', orderGroup: 'left' }}, + {{ key: 'hide_composer_status', orderGroup: 'right' }}, + ]; + window._sanitizeComposerControlOrder = function(order) {{ + const allowed = new Set(window._COMPOSER_CONTROL_TOGGLE_DEFS.concat(window._COMPOSER_SITUATIONAL_CONTROL_TOGGLE_DEFS).map(def => def.key)); + const out = []; + (Array.isArray(order) ? order : []).forEach(key => {{ + if (allowed.has(key) && !out.includes(key)) out.push(key); + }}); + return out; + }}; + window._applyComposerControlOrder = function(order) {{ window._appliedOrder = order.slice(); }}; + let primaryRenders = 0; + let situationalRenders = 0; + let autosaves = 0; + function _renderComposerControlChips() {{ primaryRenders += 1; }} + function _renderComposerSituationalControlChips() {{ situationalRenders += 1; }} + function _scheduleAppearanceAutosave() {{ autosaves += 1; }} + {helpers} + if (!_moveComposerControlOrderKey('hide_composer_voice_mode', 'hide_composer_attach')) throw new Error('same-group move failed'); + if (JSON.stringify(window._composerControlOrder) !== JSON.stringify(['hide_composer_voice_mode','hide_composer_attach','hide_composer_context','hide_composer_status'])) throw new Error('unexpected order ' + JSON.stringify(window._composerControlOrder)); + if (primaryRenders !== 1 || situationalRenders !== 1 || autosaves !== 1) throw new Error('render/autosave counts wrong'); + if (_moveComposerControlOrderKey('hide_composer_status', 'hide_composer_attach')) throw new Error('cross-group move should be rejected'); + """ + ) + result = subprocess.run([node, "-e", script], cwd=ROOT, text=True, capture_output=True) + assert result.returncode == 0, result.stderr From 948e8bfc67d8ef0ee1505830daa4587578945f6b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 1 Jul 2026 06:23:51 +0000 Subject: [PATCH 2/2] docs(changelog): composer footer control reordering (#5075) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b87f860a..5f22f9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **Reorder the chat-footer controls by dragging.** The composer footer controls (Attach, Saved prompts, Mic, Profile, Workspace, Model, Reasoning, Context, and the situational controls) can now be reordered by dragging their chips in Settings, in addition to toggling visibility. The order persists per profile and is validated/deduplicated server-side. Thanks @Paladin173. (#5075) + - **Orphaned zero-message native/CLI sidebar sessions are pruned from the sidebar.** When a CLI or API-server session that was clicked in WebUI (creating a WebUI-owned sidecar) is later deleted outside WebUI, the stale zero-message row no longer lingers in the sidebar forever — it's pruned once the backing agent session is confirmed gone. A session with any real transcript (`message_count > 0`), attention signal, or active/pending stream is always preserved (never pruned), so no transcript is lost. Thanks @enihcam. (#4988, fixes #4985) - **Extension skins can declare a base scheme (`light`/`dark`).** A registered extension skin may now set `scheme: "light" | "dark"` via `registerHermesSkin()`, so a light-only or dark-only skin forces the effective base (`.dark` class) it needs while selected — without rewriting the user's saved Theme preference. Core owns the light/dark base decision for registered skins, so extensions no longer need broad workaround CSS. The scheme value is sanitized to only `light`/`dark` and never enters the CSS token path (no injection vector); the base initializes from the pre-painted theme so a no-scheme skin can't flash a saved dark theme to light during boot. Thanks @franksong2702. (#5271)