* Refresh Russian localization bundle * feat(#5682): honor profile query during boot * fix(profile): refresh deep-link reasoning state (#5682) * test(ci): initialize reasoning transition harness state * test(ci): accept reasoning fetch context override * fix(#5682): preserve retryable profile query intent * test(#5682): accept profile switch result contract * fix(#5682): bind reasoning fetches to profile transitions * Release exp-v0.52.42: ?profile= deep links (#5730) + Russian locale refresh (#5778) --------- Co-authored-by: DrMaks22 <DrMaks22@users.noreply.github.com> Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
19 files changed
+2088
-599
No files matched your search
@@ -3,8 +3,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Deep-link straight to a profile with `?profile=<name>`.** Opening the WebUI with a `?profile=NAME` query parameter now switches to that profile on boot. The name is validated against the existing profile allowlist (a nonexistent profile boots normally, path-traversal / cross-profile attempts are rejected), the switch goes through the same access-enforced `switchToProfile`, and boot fails safe on anything invalid. Thanks @rodboev. (#5730, #5682)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Russian localization refreshed and onboarding notes localized.** The `ru` bundle was refreshed and brought to full key parity with English (identical key sets across all locales), new update/workspace UI strings were propagated to every locale, and onboarding provider notes now resolve through a localized key (with the English text preserved as fallback). Thanks @DrMaks22. (#5778)
|
||||
|
||||
- **A pending prompt no longer renders twice across a context-compaction boundary.** On reload / reattach to an active turn, a synthetic `[CONTEXT COMPACTION]` marker (a user-role row that isn't a real submitted turn) placed after your prompt was treated as the latest user message, so the tail scan missed the actual prompt right before it and rendered the same pending prompt twice. The session-load and refresh/reconnect tail scans now skip compaction markers when locating the current user message, while completed assistant rows stay hard boundaries so genuinely-repeated prompts still render. Thanks @starship-s. (#5920)
|
||||
|
||||
- **Sessions from Zed and other ACP (Agent Client Protocol) clients now show up in the sidebar.** An ACP-adapter session persists to the agent database with source `acp`, which was classified as `other` and fell through both sidebar buckets — so those conversations were completely invisible and unclickable. ACP sessions are now grouped with the CLI/TUI family (labelled "ACP"), with the same zero-message / ended-connection hiding rules as other interactive agent sessions. Thanks @ai-ag2026. (#5939)
|
||||
|
||||
+13
-2
@@ -732,15 +732,18 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
)
|
||||
|
||||
chat_ready = bool(_HERMES_FOUND and imports_ok and provider_ready)
|
||||
note_args: list[str] = []
|
||||
|
||||
if not _HERMES_FOUND or not imports_ok:
|
||||
state = "agent_unavailable"
|
||||
note_key = "onboarding_notice_system_unavailable"
|
||||
note = (
|
||||
"Hermes is not fully importable from the Web UI yet. Finish bootstrap or fix the "
|
||||
"agent install before provider setup will work."
|
||||
)
|
||||
elif chat_ready:
|
||||
state = "ready"
|
||||
note_key = "onboarding_notice_system_ready"
|
||||
provider_name = _PROVIDER_DISPLAY.get(
|
||||
provider, provider.title() if provider else "Hermes"
|
||||
)
|
||||
@@ -748,11 +751,15 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
elif provider_configured:
|
||||
state = "provider_incomplete"
|
||||
if provider == "custom" and not base_url:
|
||||
note_key = "onboarding_notice_custom_base_url_required"
|
||||
note = (
|
||||
"Hermes has a saved provider/model selection but still needs the "
|
||||
"base URL and API key required to chat."
|
||||
"Hermes has a saved provider/model selection, but the custom "
|
||||
"provider still needs a base URL. Add the API key too if that "
|
||||
"server requires one."
|
||||
)
|
||||
elif provider not in _SUPPORTED_PROVIDER_SETUPS:
|
||||
note_key = "onboarding_notice_provider_auth_required"
|
||||
note_args = [provider]
|
||||
# OAuth / unsupported provider: avoid misleading "API key" wording.
|
||||
note = (
|
||||
f"Provider '{provider}' is configured but not yet authenticated. "
|
||||
@@ -760,12 +767,14 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
"setup, then reload the Web UI."
|
||||
)
|
||||
else:
|
||||
note_key = "onboarding_notice_provider_api_key_required"
|
||||
note = (
|
||||
"Hermes has a saved provider/model selection but still needs the "
|
||||
"API key required to chat."
|
||||
)
|
||||
else:
|
||||
state = "needs_provider"
|
||||
note_key = "onboarding_notice_provider_choice_required"
|
||||
note = "Hermes is installed, but you still need to choose a provider and save working credentials."
|
||||
|
||||
return {
|
||||
@@ -774,6 +783,8 @@ def _status_from_runtime(cfg: dict, imports_ok: bool) -> dict:
|
||||
"chat_ready": chat_ready,
|
||||
"setup_state": state,
|
||||
"provider_note": note,
|
||||
"provider_note_key": note_key,
|
||||
"provider_note_args": note_args,
|
||||
"current_provider": provider or None,
|
||||
"current_model": model or None,
|
||||
"current_base_url": base_url or None,
|
||||
|
||||
+53
-6
@@ -122,6 +122,9 @@ function _prefillHasDraftText(prefillIntent){
|
||||
function _rootPrefillNeedsFreshComposer(urlSession, savedLocal, prefillIntent){
|
||||
return !urlSession&&!!savedLocal&&_prefillHasDraftText(prefillIntent);
|
||||
}
|
||||
function _profileQueryBlocksSavedLocalRestore(profileIntent, urlSession){
|
||||
return !!(profileIntent&&profileIntent.hasParam&&profileIntent.valid&&!urlSession);
|
||||
}
|
||||
async function _applyComposerPrefillOnBoot(prefillIntent){
|
||||
if(!prefillIntent||!prefillIntent.hasText) return;
|
||||
const msg=(typeof $==='function')?$('msg'):document.getElementById('msg');
|
||||
@@ -328,6 +331,14 @@ function _setButtonTooltip(btn, text){
|
||||
}
|
||||
}
|
||||
|
||||
function _uiText(key, fallback){
|
||||
if(typeof t==='function'){
|
||||
const val=t(key);
|
||||
if(val&&val!==key) return val;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function syncWorkspacePanelUI(){
|
||||
const {layout,panel,toggleBtn,edgeToggleBtn,collapseBtn}= _workspacePanelEls();
|
||||
if(!layout||!panel)return;
|
||||
@@ -340,17 +351,21 @@ function syncWorkspacePanelUI(){
|
||||
if(toggleBtn){
|
||||
toggleBtn.classList.toggle('active',isOpen);
|
||||
toggleBtn.setAttribute('aria-pressed',isOpen?'true':'false');
|
||||
_setButtonTooltip(toggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
|
||||
const label=_uiText(isOpen?'workspace_panel_hide':'workspace_panel_show', isOpen?'Hide workspace panel':'Show workspace panel');
|
||||
_setButtonTooltip(toggleBtn, label);
|
||||
toggleBtn.setAttribute('aria-label', label);
|
||||
toggleBtn.disabled=!canBrowse;
|
||||
}
|
||||
if(edgeToggleBtn){
|
||||
edgeToggleBtn.classList.toggle('active',isOpen);
|
||||
edgeToggleBtn.setAttribute('aria-expanded',isOpen?'true':'false');
|
||||
_setButtonTooltip(edgeToggleBtn, isOpen?'Hide workspace panel':'Show workspace panel');
|
||||
const label=_uiText(isOpen?'workspace_panel_hide':'workspace_panel_show', isOpen?'Hide workspace panel':'Show workspace panel');
|
||||
_setButtonTooltip(edgeToggleBtn, label);
|
||||
edgeToggleBtn.setAttribute('aria-label', label);
|
||||
edgeToggleBtn.disabled=!canBrowse;
|
||||
}
|
||||
if(collapseBtn){
|
||||
_setButtonTooltip(collapseBtn, isCompact?'Close workspace panel':'Hide workspace panel');
|
||||
_setButtonTooltip(collapseBtn, isCompact?_uiText('workspace_panel_close','Close workspace panel'):_uiText('workspace_panel_hide','Hide workspace panel'));
|
||||
}
|
||||
const hasSession=!!S.session;
|
||||
['btnUpDir','btnNewFile','btnNewFolder','btnRefreshPanel'].forEach(id=>{
|
||||
@@ -360,7 +375,9 @@ function syncWorkspacePanelUI(){
|
||||
const clearBtn=$('btnClearPreview');
|
||||
if(clearBtn){
|
||||
clearBtn.disabled=!isOpen;
|
||||
_setButtonTooltip(clearBtn, hasPreview?'Close preview':'Close');
|
||||
const label=hasPreview?_uiText('workspace_close_preview','Close preview'):_uiText('terminal_close','Close');
|
||||
_setButtonTooltip(clearBtn, label);
|
||||
clearBtn.setAttribute('aria-label', label);
|
||||
if(!isCompact) clearBtn.style.display='';
|
||||
}
|
||||
}
|
||||
@@ -2060,6 +2077,7 @@ $('modelSelect').onchange=async()=>{
|
||||
const modelState=(typeof _modelStateForSelect==='function')
|
||||
? _modelStateForSelect($('modelSelect'),selectedModel)
|
||||
: {model:selectedModel,model_provider:null};
|
||||
if(typeof clearProfileTransitionReasoningContext==='function') clearProfileTransitionReasoningContext();
|
||||
if(typeof closeModelDropdown==='function') closeModelDropdown();
|
||||
if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider);
|
||||
else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{}
|
||||
@@ -3382,6 +3400,31 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
if(profileLabel) profileLabel.textContent=S.activeProfile||'default';
|
||||
const titleLabel=$('titlebarProfileLabel');
|
||||
if(titleLabel) titleLabel.textContent=S.activeProfile||'default';
|
||||
const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;
|
||||
const _savedLocalBeforeProfileSwitch=localStorage.getItem('hermes-webui-session');
|
||||
const _profileSwitchProfileBefore=S.activeProfile||'default';
|
||||
const _profileSwitchIsDefaultBefore=!!S.activeProfileIsDefault;
|
||||
let _profileSwitchCompleted=false;
|
||||
let _profileSwitchChangedProfile=false;
|
||||
if(profileIntent&&profileIntent.hasParam){
|
||||
try{
|
||||
if(profileIntent.valid){
|
||||
if(typeof switchToProfile==='function'){
|
||||
_profileSwitchCompleted=await switchToProfile(profileIntent.name)===true;
|
||||
if(_profileSwitchCompleted){
|
||||
_profileSwitchChangedProfile=(S.activeProfile||'default')!==_profileSwitchProfileBefore||!!S.activeProfileIsDefault!==_profileSwitchIsDefaultBefore;
|
||||
if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
}
|
||||
}else{
|
||||
console.warn('[boot] ignored invalid profile query', profileIntent.name);
|
||||
if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
}catch(e){
|
||||
console.warn('[boot] profile query switch failed', e);
|
||||
}
|
||||
}
|
||||
if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip();
|
||||
// Fetch available models without blocking session restore. The static HTML
|
||||
// options are enough for first paint; the dynamic provider list can settle
|
||||
// after the saved session is visible.
|
||||
@@ -3474,8 +3517,6 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
// re-run when the browser restores the page from bfcache.
|
||||
const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = '';
|
||||
if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
|
||||
// Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load)
|
||||
if(typeof fetchReasoningChip==='function') fetchReasoningChip();
|
||||
if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator();
|
||||
const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null;
|
||||
const pwaLaunchAction=(window.HermesPWA&&typeof window.HermesPWA.launchAction==='function')
|
||||
@@ -3496,6 +3537,12 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
||||
syncTopbar();syncWorkspacePanelState();await renderSessionList();await _finalizeComposerPrefillOnBoot(prefillIntent);if(typeof startGatewaySSE==='function')startGatewaySSE();return;
|
||||
}catch(e){console.warn('[pwa] new-chat launch action failed', e);}
|
||||
}
|
||||
const _profileQueryBlocksSavedLocal=_profileQueryBlocksSavedLocalRestore(profileIntent, urlSession);
|
||||
if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){
|
||||
try{
|
||||
if(localStorage.getItem('hermes-webui-session')===_savedLocalBeforeProfileSwitch) localStorage.removeItem('hermes-webui-session');
|
||||
}catch(_){}
|
||||
}
|
||||
const savedLocal=localStorage.getItem('hermes-webui-session');
|
||||
const saved=urlSession||savedLocal;
|
||||
if(saved){
|
||||
|
||||
+979
-535
File diff suppressed because it is too large.
Load diff
+20
-20
@@ -412,10 +412,10 @@
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)">Force update</button>
|
||||
<button class="update-btn" id="btnClearUpdateLock" style="display:none" onclick="applyClearUpdateLock(this)">Clear lock and retry update</button>
|
||||
<button class="update-btn" onclick="dismissUpdate()" data-i18n="update_later">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()" data-i18n="update_now">Update Now</button>
|
||||
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)" data-i18n="update_force">Force update</button>
|
||||
<button class="update-btn" id="btnClearUpdateLock" style="display:none" onclick="applyClearUpdateLock(this)" data-i18n="update_clear_lock_retry">Clear lock and retry update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-banner" id="staleClientBanner">
|
||||
@@ -424,7 +424,7 @@
|
||||
<span id="staleClientVersions" style="font-size:11px;margin-top:4px;color:var(--muted)"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
<button class="update-btn update-primary" id="btnStaleClientRefresh" type="button" onclick="hardRefreshWebUIClient()">Hard refresh now</button>
|
||||
<button class="update-btn update-primary" id="btnStaleClientRefresh" type="button" onclick="hardRefreshWebUIClient()" data-i18n="update_hard_refresh_now">Hard refresh now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mainChat" class="main-view">
|
||||
@@ -586,7 +586,7 @@
|
||||
<span id="dropHintText">Drop files to attach</span>
|
||||
</div>
|
||||
<div class="attach-tray" id="attachTray"></div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> Listening…</div>
|
||||
<div class="mic-status" id="micStatus" style="display:none"><span class="mic-dot"></span> <span data-i18n="voice_listening">Listening…</span></div>
|
||||
<div class="voice-mode-bar" id="voiceModeBar" style="display:none">
|
||||
<span class="voice-mode-indicator" id="voiceModeIndicator"></span>
|
||||
<span class="voice-mode-label" id="voiceModeLabel"></span>
|
||||
@@ -596,10 +596,10 @@
|
||||
<div class="composer-footer">
|
||||
<div class="composer-left">
|
||||
<input type="file" id="fileInput" class="file-input-visually-hidden" multiple accept="image/*,text/*,application/pdf,application/json,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,.md,.py,.js,.ts,.yaml,.yml,.toml,.csv,.sh,.txt,.log,.env,.xls,.xlsx,.doc,.docx,.zip,.tar,.gz,.tgz,.bz2,.xz">
|
||||
<button type="button" class="icon-btn has-tooltip" id="btnAttach" data-tooltip="Attach files">
|
||||
<button type="button" class="icon-btn has-tooltip" id="btnAttach" data-tooltip="Attach files" data-i18n-title="composer_control_attach" data-i18n-aria-label="composer_control_attach" aria-label="Attach files">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
</button>
|
||||
<button type="button" class="icon-btn has-tooltip" id="btnSavedPrompts" data-tooltip="Saved prompts" onclick="toggleSavedPromptsPopup()" aria-haspopup="menu" aria-expanded="false" aria-controls="savedPromptsPopup">
|
||||
<button type="button" class="icon-btn has-tooltip" id="btnSavedPrompts" data-tooltip="Saved prompts" data-i18n-title="composer_control_saved_prompts" data-i18n-aria-label="composer_control_saved_prompts" onclick="toggleSavedPromptsPopup()" aria-haspopup="menu" aria-expanded="false" aria-controls="savedPromptsPopup" aria-label="Saved prompts">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn mic-btn has-tooltip" id="btnMic" data-tooltip="Dictate" data-i18n-title="voice_dictate" style="display:none">
|
||||
@@ -635,7 +635,7 @@
|
||||
</div>
|
||||
<div class="composer-ws-wrap">
|
||||
<div class="composer-workspace-group ws-chip" id="composerWorkspaceGroup" role="group" aria-label="Workspace controls">
|
||||
<button class="composer-workspace-files-btn" id="btnWorkspacePanelToggle" type="button" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false" aria-label="Toggle workspace files panel">
|
||||
<button class="composer-workspace-files-btn" id="btnWorkspacePanelToggle" type="button" onclick="toggleWorkspacePanel()" title="Show workspace panel" aria-pressed="false" aria-label="Show workspace panel">
|
||||
<span class="composer-workspace-icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
|
||||
</button>
|
||||
<button class="composer-workspace-chip" id="composerWorkspaceChip" type="button" onclick="toggleComposerWsDropdown()" title="Switch workspace" aria-label="Switch workspace" aria-haspopup="true" aria-expanded="false" aria-controls="composerWsDropdown" disabled>
|
||||
@@ -694,7 +694,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="savedPromptsPopup" class="saved-prompts-popup" style="display:none" role="menu" aria-label="Saved prompts"></div>
|
||||
<div id="savedPromptsPopup" class="saved-prompts-popup" style="display:none" role="menu" aria-label="Saved prompts" data-i18n-aria-label="composer_control_saved_prompts"></div>
|
||||
<div class="composer-right">
|
||||
<span class="composer-status" id="composerStatus" style="display:none"></span>
|
||||
<div class="ctx-indicator-wrap" id="ctxIndicatorWrap" style="display:none">
|
||||
@@ -719,7 +719,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<span class="bg-badge" id="bgBadge" style="display:none" title="Background tasks running">0</span>
|
||||
<button class="send-btn has-tooltip has-tooltip--left" id="btnSend" data-tooltip="Send message" disabled>
|
||||
<button class="send-btn has-tooltip has-tooltip--left" id="btnSend" data-tooltip="Send message" data-i18n-title="composer_send" data-i18n-aria-label="composer_send" disabled>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1648,19 +1648,19 @@
|
||||
<div class="workspace-panel-title-group"><span id="workspacePanelHeading" class="workspace-panel-heading" title="Workspace">Workspace</span><span id="workspaceHiddenIndicator" class="workspace-hidden-indicator" data-i18n-title="workspace_hidden_files_visible_title" title="Hidden files are visible — click for options" hidden onclick="toggleWorkspacePrefsMenu(event)"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span data-i18n="workspace_hidden_files_visible">hidden visible</span></span></div>
|
||||
<span class="git-badge" id="gitBadge" style="display:none"></span>
|
||||
<div class="panel-actions">
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUpDir" data-tooltip="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFile" data-tooltip="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFolder" data-tooltip="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnRefreshPanel" data-tooltip="Refresh" onclick="if(S.session)refreshWorkspacePanel()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUploadWorkspace" data-tooltip="Upload file" onclick="triggerWorkspaceUpload()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUpDir" data-tooltip="Parent directory" data-i18n-title="workspace_parent_directory" data-i18n-aria-label="workspace_parent_directory" aria-label="Parent directory" onclick="navigateUp()" style="display:none"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFile" data-tooltip="New file" data-i18n-title="new_file" data-i18n-aria-label="new_file" aria-label="New file" onclick="promptNewFile()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnNewFolder" data-tooltip="New folder" data-i18n-title="new_folder" data-i18n-aria-label="new_folder" aria-label="New folder" onclick="promptNewFolder()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnRefreshPanel" data-tooltip="Refresh" data-i18n-title="kanban_refresh" data-i18n-aria-label="kanban_refresh" aria-label="Refresh" onclick="if(S.session)refreshWorkspacePanel()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" /></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnUploadWorkspace" data-tooltip="Upload file" data-i18n-title="workspace_upload_file" data-i18n-aria-label="workspace_upload_file" aria-label="Upload file" onclick="triggerWorkspaceUpload()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg></button>
|
||||
<button class="panel-icon-btn has-tooltip has-tooltip--bottom" id="btnWorkspacePrefs" data-tooltip="Workspace options" data-i18n-title="workspace_options" aria-haspopup="true" aria-expanded="false" onclick="toggleWorkspacePrefsMenu(event)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg><span class="workspace-prefs-dot" id="workspacePrefsDot" hidden></span></button>
|
||||
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
<button class="panel-icon-btn close-preview has-tooltip has-tooltip--bottom" id="btnClearPreview" data-tooltip="Close preview" data-i18n-title="workspace_close_preview" data-i18n-aria-label="workspace_close_preview" aria-label="Close preview"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="workspaceFileInput" class="file-input-visually-hidden" multiple accept="image/*,text/*,.pdf,.json,.csv,.md,.py,.js,.ts,.yaml,.yml,.toml,.zip,.tar,.gz,.tgz,.bz2,.xz">
|
||||
<div class="workspace-panel-tabs" role="tablist" aria-label="Workspace panel views">
|
||||
<button class="workspace-panel-tab active" id="workspaceFilesTab" type="button" onclick="switchWorkspacePanelTab('files')" role="tab" aria-selected="true">Files</button>
|
||||
<button class="workspace-panel-tab" id="workspaceArtifactsTab" type="button" onclick="switchWorkspacePanelTab('artifacts')" role="tab" aria-selected="false">Artifacts <span id="workspaceArtifactsCount" class="workspace-artifacts-count">0</span></button>
|
||||
<div class="workspace-panel-tabs" role="tablist" aria-label="Workspace panel views" data-i18n-aria-label="workspace_panel_views">
|
||||
<button class="workspace-panel-tab active" id="workspaceFilesTab" type="button" onclick="switchWorkspacePanelTab('files')" role="tab" aria-selected="true" data-i18n="workspace_files_tab">Files</button>
|
||||
<button class="workspace-panel-tab" id="workspaceArtifactsTab" type="button" onclick="switchWorkspacePanelTab('artifacts')" role="tab" aria-selected="false"><span data-i18n="workspace_artifacts_tab">Artifacts</span> <span id="workspaceArtifactsCount" class="workspace-artifacts-count">0</span></button>
|
||||
<button class="workspace-panel-tab" id="workspaceTodosTab" type="button" onclick="switchWorkspacePanelTab('todos')" role="tab" aria-selected="false" hidden data-i18n="tab_todos">Todos</button>
|
||||
</div>
|
||||
<div class="breadcrumb-bar" id="breadcrumbBar" style="display:none"></div>
|
||||
|
||||
+13
-2
@@ -235,6 +235,16 @@ function _providerStatusLabel(system){
|
||||
return t('onboarding_check_provider_pending');
|
||||
}
|
||||
|
||||
function _localizedOnboardingProviderNote(system){
|
||||
const key=system&&system.provider_note_key;
|
||||
if(key){
|
||||
const args=Array.isArray(system&&system.provider_note_args)?system.provider_note_args:[];
|
||||
const localized=t(key,...args);
|
||||
if(localized&&localized!==key&&!/\{\d+\}/.test(localized))return localized;
|
||||
}
|
||||
return (system&&system.provider_note)||'';
|
||||
}
|
||||
|
||||
function _renderOnboardingBody(){
|
||||
const body=$('onboardingBody');
|
||||
if(!body||!ONBOARDING.status)return;
|
||||
@@ -250,7 +260,8 @@ function _renderOnboardingBody(){
|
||||
if(key==='system'){
|
||||
const hermesOk=system.hermes_found&&system.imports_ok;
|
||||
const setupOk=!!system.chat_ready;
|
||||
_setOnboardingNotice(system.provider_note|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
|
||||
const providerNote=_localizedOnboardingProviderNote(system);
|
||||
_setOnboardingNotice(providerNote|| (setupOk?t('onboarding_notice_system_ready'):t('onboarding_notice_system_unavailable')),setupOk?'success':(hermesOk?'info':'warn'));
|
||||
body.innerHTML=`
|
||||
<div class="onboarding-panel-grid">
|
||||
<div class="onboarding-check ${hermesOk?'ok':'warn'}"><strong>${t('onboarding_check_agent')}</strong><span>${hermesOk?t('onboarding_check_agent_ready'):t('onboarding_check_agent_missing')}</span></div>
|
||||
@@ -260,7 +271,7 @@ function _renderOnboardingBody(){
|
||||
<div class="onboarding-copy">
|
||||
<p><strong>${t('onboarding_config_file')}</strong> ${esc(system.config_path||t('onboarding_unknown'))}</p>
|
||||
<p><strong>${t('onboarding_env_file')}</strong> ${esc(system.env_path||t('onboarding_unknown'))}</p>
|
||||
<p>${esc(system.provider_note||'')}</p>
|
||||
${providerNote?`<p>${esc(providerNote)}</p>`:''}
|
||||
${system.current_provider?`<p><strong>${t('onboarding_current_provider')}</strong> ${esc(system.current_provider)}${system.current_model?` — ${esc(system.current_model)}`:''}</p>`:''}
|
||||
${system.current_base_url?`<p><strong>${t('onboarding_base_url_label')}</strong> ${esc(system.current_base_url)}</p>`:''}
|
||||
${system.missing_modules&&system.missing_modules.length?`<p><strong>${t('onboarding_missing_imports')}</strong> ${esc(system.missing_modules.join(', '))}</p>`:''}
|
||||
|
||||
+11
-13
@@ -2658,7 +2658,7 @@ function _kanbanLooksLikeStaleClientError(err){
|
||||
function _kanbanUnavailableHtml(err){
|
||||
const raw = String((err && err.message) || err || '');
|
||||
if (_kanbanLooksLikeStaleClientError(err)) {
|
||||
return `<div class="main-view-empty"><div class="main-view-empty-title">Kanban needs a hard refresh</div><div class="main-view-empty-subtitle">The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.</div><button class="btn primary" type="button" onclick="hardRefreshWebUIClient()">Hard refresh now</button><div class="main-view-empty-subtitle">Original error: ${esc(raw || 'not found')}</div></div>`;
|
||||
return `<div class="main-view-empty"><div class="main-view-empty-title">Kanban needs a hard refresh</div><div class="main-view-empty-subtitle">The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.</div><button class="btn primary" type="button" onclick="hardRefreshWebUIClient()">${esc(t('update_hard_refresh_now')||'Hard refresh now')}</button><div class="main-view-empty-subtitle">Original error: ${esc(raw || 'not found')}</div></div>`;
|
||||
}
|
||||
const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`;
|
||||
return `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
|
||||
@@ -6920,7 +6920,7 @@ async function switchToProfile(name) {
|
||||
// already on this profile, so paths like activateCurrentProfile() (which
|
||||
// doesn't pre-check) can't flash a skeleton→restore for a click that changes
|
||||
// nothing. (#4662 Opus gate)
|
||||
if (name && name === S.activeProfile) return;
|
||||
if (name && name === S.activeProfile) return true;
|
||||
S._pendingSessionToolsets=null;
|
||||
// Profile switches are per-client cookie/TLS scoped, so a running stream in
|
||||
// the current session can safely continue while this tab moves to another
|
||||
@@ -7001,7 +7001,7 @@ async function switchToProfile(name) {
|
||||
// the single source of truth for switch failure and is gated on _switchGen, so the
|
||||
// error surfaces ONLY when the CURRENT switch genuinely fails (@rodboev review, #4662).
|
||||
const data = await api('/api/profile/switch', { method: 'POST', body: JSON.stringify({ name }), timeoutToast: false });
|
||||
if (_switchGen !== _profileSwitchGeneration) return;
|
||||
if (_switchGen !== _profileSwitchGeneration) return false;
|
||||
S.activeProfile = data.active || name;
|
||||
S.activeProfileIsDefault = !!data.is_default;
|
||||
const targetActiveProfile = S.activeProfile || 'default';
|
||||
@@ -7017,13 +7017,6 @@ async function switchToProfile(name) {
|
||||
sessionInProgress = true;
|
||||
}
|
||||
}
|
||||
// #4650 review: a profile switch can change agent.reasoning_effort (and other
|
||||
// reasoning inputs like base_url) WITHOUT changing the default model/provider,
|
||||
// which is all the reasoning-chip cache key tracks. Force exactly one reasoning
|
||||
// refetch for the new profile so the chip reflects the new profile's effort
|
||||
// (the syncTopbar() calls below route through syncReasoningChip()).
|
||||
if (typeof _lastReasoningFetchKey !== 'undefined') _lastReasoningFetchKey = null;
|
||||
|
||||
// Reconnect the gateway SSE to the NEW profile's watcher. The backend watcher
|
||||
// registry is now profile-keyed (#3629), but this tab's existing EventSource is
|
||||
// still subscribed to the PREVIOUS profile's watcher — and the probe-based
|
||||
@@ -7087,6 +7080,9 @@ async function switchToProfile(name) {
|
||||
if (S.session && !sessionInProgress) {
|
||||
S.session.profile = data.active || name;
|
||||
}
|
||||
if (typeof refreshProfileTransitionReasoningChip === 'function') {
|
||||
refreshProfileTransitionReasoningChip(data.default_model, data.default_model_provider);
|
||||
}
|
||||
|
||||
// ── Apply workspace ────────────────────────────────────────────────────
|
||||
if (data.default_workspace) {
|
||||
@@ -7122,7 +7118,7 @@ async function switchToProfile(name) {
|
||||
const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed';
|
||||
if (typeof _setProfileSwitchListEmbargo === 'function') _setProfileSwitchListEmbargo(false);
|
||||
await renderSessionList();
|
||||
if (_switchGen !== _profileSwitchGeneration) return;
|
||||
if (_switchGen !== _profileSwitchGeneration) return false;
|
||||
if (workspaceVisible && typeof clearWorkspaceTreeSkeleton === 'function') clearWorkspaceTreeSkeleton();
|
||||
showToast(t('profile_switched', name));
|
||||
} else if (sessionInProgress) {
|
||||
@@ -7130,7 +7126,7 @@ async function switchToProfile(name) {
|
||||
// Start a new session for the new profile so nothing gets cross-tagged.
|
||||
const workspaceVisible = typeof _workspacePanelMode !== 'undefined' && _workspacePanelMode !== 'closed';
|
||||
await newSession(false, {awaitWorkspaceLoad: workspaceVisible});
|
||||
if (_switchGen !== _profileSwitchGeneration) return;
|
||||
if (_switchGen !== _profileSwitchGeneration) return false;
|
||||
// Keep topbar chips (workspace/profile) in sync after creating the
|
||||
// new profile-scoped session.
|
||||
syncTopbar();
|
||||
@@ -7144,7 +7140,7 @@ async function switchToProfile(name) {
|
||||
// the superseded switch would clear the newer switch's workspace skeleton
|
||||
// and pop a stale toast. Mirrors the no-messages branch guard below.
|
||||
// (@rodboev/greptile review, #4662)
|
||||
if (_switchGen !== _profileSwitchGeneration) return;
|
||||
if (_switchGen !== _profileSwitchGeneration) return false;
|
||||
if (typeof _openProfileSwitchSessionBrowser === 'function') _openProfileSwitchSessionBrowser();
|
||||
// Safety net: if the new session has no workspace, newSession() won't have
|
||||
// painted the file tree — clear the up-front skeleton so it can't strand
|
||||
@@ -7185,6 +7181,7 @@ async function switchToProfile(name) {
|
||||
|
||||
await _profileSwitchPanelLoad();
|
||||
_refreshProfileSwitchBackground(_switchGen);
|
||||
return true;
|
||||
|
||||
} catch (e) {
|
||||
// Revert the optimistic name update on error
|
||||
@@ -7211,6 +7208,7 @@ async function switchToProfile(name) {
|
||||
clearWorkspaceTreeSkeleton();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
// Always remove loading indicator regardless of success or failure
|
||||
if (_switchGen === _profileSwitchGeneration && _chip) { _chip.classList.remove('switching'); _chip.disabled = false; }
|
||||
|
||||
@@ -1441,6 +1441,9 @@ async function _switchProfileForSessionLoad(profile){
|
||||
else localStorage.removeItem('hermes-webui-model');
|
||||
if(data.default_model) window._defaultModel=data.default_model;
|
||||
if(data.default_model_provider) window._activeProvider=data.default_model_provider;
|
||||
if(typeof refreshProfileTransitionReasoningChip==='function'){
|
||||
refreshProfileTransitionReasoningChip(data.default_model,data.default_model_provider);
|
||||
}
|
||||
if(typeof startGatewaySSE==='function') startGatewaySSE();
|
||||
if(typeof syncTopbar==='function') syncTopbar();
|
||||
if(typeof _setProfileSwitchListEmbargo==='function') _setProfileSwitchListEmbargo(false);
|
||||
@@ -3799,6 +3802,32 @@ function _composerPrefillIntentFromLocation(){
|
||||
};
|
||||
}catch(_e){return empty;}
|
||||
}
|
||||
function _profileQueryIntentFromLocation(){
|
||||
const empty={hasParam:false,valid:false,name:''};
|
||||
if(typeof window==='undefined'||!window.location) return empty;
|
||||
try{
|
||||
const qs=new URLSearchParams(window.location.search||'');
|
||||
if(!qs.has('profile')) return empty;
|
||||
const name=String(qs.get('profile')||'');
|
||||
return {
|
||||
hasParam:true,
|
||||
valid:/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name),
|
||||
name
|
||||
};
|
||||
}catch(_e){return empty;}
|
||||
}
|
||||
function _consumeProfileQueryParamFromLocation(){
|
||||
if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return;
|
||||
try{
|
||||
const current=new URL(window.location.href);
|
||||
const before=current.searchParams.toString();
|
||||
current.searchParams.delete('profile');
|
||||
const after=current.searchParams.toString();
|
||||
if(after===before) return;
|
||||
const next=current.pathname+(after?`?${after}`:'')+(current.hash||'');
|
||||
window.history.replaceState(window.history.state||null,'',next);
|
||||
}catch(_e){}
|
||||
}
|
||||
function _consumeComposerPrefillParamsFromLocation(){
|
||||
if(typeof window==='undefined'||!window.location||!window.history||typeof window.history.replaceState!=='function') return;
|
||||
try{
|
||||
|
||||
+41
-7
@@ -4468,6 +4468,7 @@ if(document.readyState==='loading'){
|
||||
// ── Reasoning effort chip ────────────────────────────────────────────────────
|
||||
let _currentReasoningEffort=null;
|
||||
let _currentReasoningEffortsSupported=null;
|
||||
let _profileTransitionReasoningContext=null;
|
||||
|
||||
function _normalizeReasoningEffort(eff){
|
||||
return String(eff||'').trim().toLowerCase();
|
||||
@@ -4486,6 +4487,14 @@ function _formatReasoningEffortLabel(effort){
|
||||
}
|
||||
|
||||
function _reasoningEffortContext(){
|
||||
const transition=_profileTransitionReasoningContext;
|
||||
const session=S&&S.session;
|
||||
if(transition&&(!session||session.profile!==transition.profile)){
|
||||
const ctx={};
|
||||
if(transition.model) ctx.model=transition.model;
|
||||
if(transition.provider) ctx.provider=transition.provider;
|
||||
return ctx;
|
||||
}
|
||||
const sel=$('modelSelect');
|
||||
const model=(S&&S.session&&S.session.model)||(sel&&sel.value)||'';
|
||||
let provider=(S&&S.session&&S.session.model_provider)||'';
|
||||
@@ -4575,11 +4584,11 @@ let _lastReasoningFetchKey=null;
|
||||
// a different agent.reasoning_effort) — #4650 review.
|
||||
let _reasoningFetchSeq=0;
|
||||
|
||||
function fetchReasoningChip(){
|
||||
function fetchReasoningChip(keyOverride){
|
||||
// Set the cache key OPTIMISTICALLY before the request so rapid routine syncs
|
||||
// while this GET is in flight short-circuit instead of re-dispatching (that
|
||||
// in-flight window is exactly where the #4650 storm lived).
|
||||
const key=_reasoningEffortQuery();
|
||||
const key=keyOverride===undefined?_reasoningEffortQuery():keyOverride;
|
||||
const seq=++_reasoningFetchSeq;
|
||||
_lastReasoningFetchKey=key;
|
||||
api('/api/reasoning'+key).then(function(st){
|
||||
@@ -4597,6 +4606,23 @@ function fetchReasoningChip(){
|
||||
});
|
||||
}
|
||||
|
||||
function refreshProfileTransitionReasoningChip(model, provider){
|
||||
_profileTransitionReasoningContext={profile:(S&&S.activeProfile)||'default',model,provider};
|
||||
_currentReasoningEffort=null;
|
||||
_currentReasoningEffortsSupported=null;
|
||||
_lastReasoningFetchKey=null;
|
||||
++_reasoningFetchSeq;
|
||||
_applyReasoningChip('', {supported_efforts:[]});
|
||||
const params=new URLSearchParams();
|
||||
if(model) params.set('model',model);
|
||||
if(provider) params.set('provider',provider);
|
||||
fetchReasoningChip(params.size?'?'+params.toString():undefined);
|
||||
}
|
||||
|
||||
function clearProfileTransitionReasoningContext(){
|
||||
_profileTransitionReasoningContext=null;
|
||||
}
|
||||
|
||||
function syncReasoningChip(){
|
||||
// #4650: syncTopbar() calls this on every routine UI refresh, and during
|
||||
// streaming those fire at high frequency. Before a9ce2889 this served the
|
||||
@@ -9440,6 +9466,13 @@ function _showUpdateBanner(data){
|
||||
const summaryMode=window._whatsNewSummaryEnabled===true?'summary':'diff';
|
||||
_renderUpdateWhatsNewLinks(data,{mode:summaryMode});
|
||||
}
|
||||
function _i18nUpdateText(key, fallback){
|
||||
if(typeof t==='function'){
|
||||
const val=t(key);
|
||||
if(val&&val!==key) return val;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
function dismissUpdate(){
|
||||
const b=$('updateBanner');if(b)b.classList.remove('visible');
|
||||
sessionStorage.setItem('hermes-update-dismissed','1');
|
||||
@@ -9451,24 +9484,25 @@ function _isUpdateApplyNetworkError(error){
|
||||
}
|
||||
function _formatUpdateApplyExceptionMessage(error){
|
||||
if(_isUpdateApplyNetworkError(error)){
|
||||
return 'Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.';
|
||||
return _i18nUpdateText('update_failed_network','Update failed: could not reach the WebUI server. It may have restarted or the connection was interrupted. Please wait a few seconds, reload the page, then check the server if it still does not come back.');
|
||||
}
|
||||
const message=(error&&error.message)||String(error||'unknown error');
|
||||
return 'Update failed: '+message;
|
||||
return _i18nUpdateText('update_failed_prefix','Update failed: ')+message;
|
||||
}
|
||||
async function applyUpdates(){
|
||||
if(window._updateApplyInFlight) return;
|
||||
window._updateApplyInFlight=true;
|
||||
const updateText=(key,fallback)=>(typeof _i18nUpdateText==='function'?_i18nUpdateText(key,fallback):fallback);
|
||||
const btn=$('btnApplyUpdate');
|
||||
const resetApplyButton=(delayMs)=>{
|
||||
const reset=()=>{
|
||||
window._updateApplyInFlight=false;
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
if(btn){btn.disabled=false;btn.textContent=updateText('update_now','Update Now');}
|
||||
};
|
||||
if(delayMs>0) setTimeout(reset,delayMs);
|
||||
else reset();
|
||||
};
|
||||
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||||
if(btn){btn.disabled=true;btn.textContent=updateText('update_updating','Updating\u2026');}
|
||||
const errEl=$('updateError');
|
||||
if(errEl){errEl.style.display='none';errEl.textContent='';}
|
||||
// Hide any leftover force-update button from a prior conflict so a fresh
|
||||
@@ -9479,7 +9513,7 @@ async function applyUpdates(){
|
||||
if(window._updateData?.agent?.behind>0) targets.push('agent');
|
||||
if(window._updateData?.webui?.behind>0) targets.push('webui');
|
||||
if(!targets.length){
|
||||
const msg='No update target selected. Refresh update status and retry.';
|
||||
const msg=updateText('update_no_target','No update target selected. Refresh update status and retry.');
|
||||
if(errEl){errEl.textContent=msg;errEl.style.display='block';}
|
||||
else showToast(msg,5000,'error');
|
||||
resetApplyButton(0);
|
||||
|
||||
@@ -0,0 +1,832 @@
|
||||
"""Regression tests for #5682 profile query boot switching."""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.resolve()
|
||||
SESSIONS_JS_PATH = REPO_ROOT / "static" / "sessions.js"
|
||||
BOOT_JS_PATH = REPO_ROOT / "static" / "boot.js"
|
||||
PANELS_JS_PATH = REPO_ROOT / "static" / "panels.js"
|
||||
UI_JS_PATH = REPO_ROOT / "static" / "ui.js"
|
||||
SESSIONS_JS = SESSIONS_JS_PATH.read_text(encoding="utf-8")
|
||||
BOOT_JS = BOOT_JS_PATH.read_text(encoding="utf-8")
|
||||
PANELS_JS = PANELS_JS_PATH.read_text(encoding="utf-8")
|
||||
UI_JS = UI_JS_PATH.read_text(encoding="utf-8")
|
||||
NODE = shutil.which("node")
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
def _run_node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
[NODE],
|
||||
input=source,
|
||||
cwd=str(REPO_ROOT),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(result.stderr)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _node_prelude() -> str:
|
||||
return f"""
|
||||
const sessionsSrc = {SESSIONS_JS!r};
|
||||
const bootSrc = {BOOT_JS!r};
|
||||
function extractFunc(src, name) {{
|
||||
const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start);
|
||||
let depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
function evalSession(name) {{
|
||||
globalThis[name] = (0, eval)('(' + extractFunc(sessionsSrc, name) + ')');
|
||||
}}
|
||||
function evalBoot(name) {{
|
||||
globalThis[name] = (0, eval)('(' + extractFunc(bootSrc, name) + ')');
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def test_valid_profile_query_switches_before_restore_and_cleans_url():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: { from: 'test' },
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
console.warn = (...args) => { throw new Error('unexpected warn: ' + args.join(' ')); };
|
||||
applyUrl('/app/?profile=vops&q=hello&prompt=hi&send=1&keep=1#frag');
|
||||
global.localStorage = {
|
||||
store: { 'hermes-webui-session': 'saved-local' },
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
this.store[key] = String(value);
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
};
|
||||
evalSession('_profileQueryIntentFromLocation');
|
||||
evalSession('_consumeProfileQueryParamFromLocation');
|
||||
evalSession('_consumeComposerPrefillParamsFromLocation');
|
||||
evalSession('_sessionUrlForSid');
|
||||
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
||||
const intent = _profileQueryIntentFromLocation();
|
||||
global.S = { activeProfile: 'default', activeProfileIsDefault: true };
|
||||
const switched = [];
|
||||
global.switchToProfile = async (name) => {
|
||||
switched.push(name);
|
||||
S.activeProfile = name;
|
||||
S.activeProfileIsDefault = false;
|
||||
localStorage.setItem('hermes-webui-session', 'fresh-local');
|
||||
return true;
|
||||
};
|
||||
(async () => {
|
||||
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
||||
const profileSwitchProfileBefore = S.activeProfile || 'default';
|
||||
const profileSwitchIsDefaultBefore = !!S.activeProfileIsDefault;
|
||||
let profileSwitchCompleted = false;
|
||||
let profileSwitchChangedProfile = false;
|
||||
if (intent && intent.hasParam) {
|
||||
try {
|
||||
if (intent.valid) {
|
||||
if (typeof switchToProfile === 'function') {
|
||||
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
||||
if (profileSwitchCompleted) {
|
||||
profileSwitchChangedProfile = (S.activeProfile || 'default') !== profileSwitchProfileBefore || !!S.activeProfileIsDefault !== profileSwitchIsDefaultBefore;
|
||||
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[boot] ignored invalid profile query', intent.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[boot] profile query switch failed', e);
|
||||
}
|
||||
}
|
||||
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
||||
if (blocksSavedLocal && profileSwitchCompleted && profileSwitchChangedProfile && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
||||
const savedLocalAfterSuppress = localStorage.getItem('hermes-webui-session');
|
||||
const savedLocalAfterReload = localStorage.getItem('hermes-webui-session');
|
||||
const keepsExplicitSession = _profileQueryBlocksSavedLocalRestore(intent, 'session-123');
|
||||
const afterProfile = window.location.pathname + window.location.search + window.location.hash;
|
||||
const promoted = _sessionUrlForSid('abc 123');
|
||||
_consumeComposerPrefillParamsFromLocation();
|
||||
const afterPrefill = window.location.pathname + window.location.search + window.location.hash;
|
||||
const profilePos = bootSrc.indexOf("const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;");
|
||||
const renderPos = bootSrc.indexOf("await renderSessionList();", profilePos);
|
||||
const savedPos = bootSrc.indexOf("const saved=urlSession||savedLocal;", profilePos);
|
||||
const loadPos = bootSrc.indexOf("await loadSession(saved, {preserveActiveInput:true});", profilePos);
|
||||
const consumePos = bootSrc.indexOf("if(typeof _consumeProfileQueryParamFromLocation==='function') _consumeProfileQueryParamFromLocation();", profilePos);
|
||||
const completedPos = bootSrc.indexOf("_profileSwitchCompleted=await switchToProfile(profileIntent.name)===true;", profilePos);
|
||||
const changedPos = bootSrc.indexOf("_profileSwitchChangedProfile=", completedPos);
|
||||
const cleanupGuardPos = bootSrc.indexOf("if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){", profilePos);
|
||||
const initialReasoningFetchPos = bootSrc.indexOf("if(typeof fetchReasoningChip==='function'&&(!_profileSwitchCompleted||!_profileSwitchChangedProfile)) fetchReasoningChip();", profilePos);
|
||||
console.log(JSON.stringify({ intent, switched, promoted, afterProfile, afterPrefill, historyCalls: window.history.calls, profilePos, renderPos, savedPos, loadPos, consumePos, completedPos, changedPos, cleanupGuardPos, initialReasoningFetchPos, savedLocalBefore, savedLocalAfterSuppress, savedLocalAfterReload, blocksSavedLocal, keepsExplicitSession }));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["intent"] == {"hasParam": True, "valid": True, "name": "vops"}
|
||||
assert payload["switched"] == ["vops"]
|
||||
assert payload["promoted"] == "/app/session/abc%20123?keep=1#frag"
|
||||
assert payload["afterProfile"] == "/app/?q=hello&prompt=hi&send=1&keep=1#frag"
|
||||
assert payload["afterPrefill"] == "/app/?keep=1#frag"
|
||||
assert payload["historyCalls"][0]["url"] == "/app/?q=hello&prompt=hi&send=1&keep=1#frag"
|
||||
assert payload["historyCalls"][1]["url"] == "/app/?keep=1#frag"
|
||||
assert payload["profilePos"] >= 0
|
||||
assert payload["renderPos"] > payload["profilePos"]
|
||||
assert payload["savedPos"] > payload["profilePos"]
|
||||
assert payload["loadPos"] > payload["savedPos"]
|
||||
assert payload["consumePos"] > payload["profilePos"]
|
||||
assert payload["completedPos"] > payload["profilePos"]
|
||||
assert payload["changedPos"] > payload["completedPos"]
|
||||
assert payload["cleanupGuardPos"] > payload["consumePos"]
|
||||
assert payload["initialReasoningFetchPos"] > payload["completedPos"]
|
||||
assert payload["savedLocalBefore"] == "saved-local"
|
||||
assert payload["savedLocalAfterSuppress"] == "fresh-local"
|
||||
assert payload["savedLocalAfterReload"] == "fresh-local"
|
||||
assert payload["blocksSavedLocal"] is True
|
||||
assert payload["keepsExplicitSession"] is False
|
||||
|
||||
|
||||
def test_noop_profile_query_switch_keeps_saved_local_state():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: { from: 'test' },
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
applyUrl('/app/?profile=default&q=hello&keep=1#frag');
|
||||
global.S = { activeProfile: 'default', activeProfileIsDefault: true };
|
||||
global.localStorage = {
|
||||
store: { 'hermes-webui-session': 'saved-local' },
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
};
|
||||
evalSession('_profileQueryIntentFromLocation');
|
||||
evalSession('_consumeProfileQueryParamFromLocation');
|
||||
evalSession('_consumeComposerPrefillParamsFromLocation');
|
||||
evalSession('_sessionUrlForSid');
|
||||
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
||||
const intent = _profileQueryIntentFromLocation();
|
||||
global.switchToProfile = async () => true;
|
||||
(async () => {
|
||||
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
||||
const profileSwitchProfileBefore = S.activeProfile || 'default';
|
||||
const profileSwitchIsDefaultBefore = !!S.activeProfileIsDefault;
|
||||
let profileSwitchCompleted = false;
|
||||
let profileSwitchChangedProfile = false;
|
||||
if (intent && intent.hasParam) {
|
||||
try {
|
||||
if (intent.valid) {
|
||||
if (typeof switchToProfile === 'function') {
|
||||
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
||||
if (profileSwitchCompleted) {
|
||||
profileSwitchChangedProfile = (S.activeProfile || 'default') !== profileSwitchProfileBefore || !!S.activeProfileIsDefault !== profileSwitchIsDefaultBefore;
|
||||
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[boot] ignored invalid profile query', intent.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[boot] profile query switch failed', e);
|
||||
}
|
||||
}
|
||||
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
||||
if (blocksSavedLocal && profileSwitchCompleted && profileSwitchChangedProfile && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
||||
const cleanupGuardPos = bootSrc.indexOf("if(_profileQueryBlocksSavedLocal&&_profileSwitchCompleted&&_profileSwitchChangedProfile){", bootSrc.indexOf("const profileIntent=(typeof _profileQueryIntentFromLocation==='function')?_profileQueryIntentFromLocation():null;"));
|
||||
console.log(JSON.stringify({
|
||||
intent,
|
||||
blocksSavedLocal,
|
||||
profileSwitchCompleted,
|
||||
profileSwitchChangedProfile,
|
||||
savedLocalBefore,
|
||||
savedLocalAfter: localStorage.getItem('hermes-webui-session'),
|
||||
cleanupGuardPos,
|
||||
}));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["intent"] == {"hasParam": True, "valid": True, "name": "default"}
|
||||
assert payload["blocksSavedLocal"] is True
|
||||
assert payload["profileSwitchCompleted"] is True
|
||||
assert payload["profileSwitchChangedProfile"] is False
|
||||
assert payload["savedLocalBefore"] == "saved-local"
|
||||
assert payload["savedLocalAfter"] == "saved-local"
|
||||
assert payload["cleanupGuardPos"] >= 0
|
||||
|
||||
|
||||
def test_failed_profile_query_switch_keeps_saved_local_state():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: null,
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
const warns = [];
|
||||
console.warn = (...args) => { warns.push(args.map(String)); };
|
||||
applyUrl('/app/?profile=vops&q=hello&keep=1#frag');
|
||||
global.localStorage = {
|
||||
store: { 'hermes-webui-session': 'saved-local' },
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
};
|
||||
evalSession('_profileQueryIntentFromLocation');
|
||||
evalSession('_consumeProfileQueryParamFromLocation');
|
||||
evalSession('_consumeComposerPrefillParamsFromLocation');
|
||||
evalSession('_sessionUrlForSid');
|
||||
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
||||
const intent = _profileQueryIntentFromLocation();
|
||||
global.switchToProfile = async () => { throw new Error('boom'); };
|
||||
(async () => {
|
||||
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
||||
let profileSwitchCompleted = false;
|
||||
if (intent && intent.hasParam) {
|
||||
try {
|
||||
if (intent.valid) {
|
||||
if (typeof switchToProfile === 'function') {
|
||||
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
||||
if (profileSwitchCompleted && typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
} else {
|
||||
console.warn('[boot] ignored invalid profile query', intent.name);
|
||||
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[boot] profile query switch failed', e);
|
||||
}
|
||||
}
|
||||
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
||||
if (blocksSavedLocal && profileSwitchCompleted && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
||||
const afterBoot = window.location.pathname + window.location.search + window.location.hash;
|
||||
_consumeComposerPrefillParamsFromLocation();
|
||||
const afterPrefill = window.location.pathname + window.location.search + window.location.hash;
|
||||
const promoted = _sessionUrlForSid('abc 123');
|
||||
console.log(JSON.stringify({
|
||||
blocksSavedLocal,
|
||||
profileSwitchCompleted,
|
||||
savedLocalAfter: localStorage.getItem('hermes-webui-session'),
|
||||
afterBoot,
|
||||
afterPrefill,
|
||||
promoted,
|
||||
warns,
|
||||
}));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["blocksSavedLocal"] is True
|
||||
assert payload["profileSwitchCompleted"] is False
|
||||
assert payload["savedLocalAfter"] == "saved-local"
|
||||
assert payload["afterBoot"] == "/app/?profile=vops&q=hello&keep=1#frag"
|
||||
assert payload["afterPrefill"] == "/app/?profile=vops&keep=1#frag"
|
||||
assert payload["promoted"] == "/app/session/abc%20123?profile=vops&keep=1#frag"
|
||||
assert payload["warns"] == [["[boot] profile query switch failed", "Error: boom"]]
|
||||
|
||||
|
||||
def test_unsuccessful_profile_query_result_keeps_retry_url_without_warning():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: null,
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
const warns = [];
|
||||
console.warn = (...args) => { warns.push(args.map(String)); };
|
||||
applyUrl('/app/?profile=vops&q=hello&keep=1#frag');
|
||||
global.localStorage = {
|
||||
store: { 'hermes-webui-session': 'saved-local' },
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
};
|
||||
evalSession('_profileQueryIntentFromLocation');
|
||||
evalSession('_consumeProfileQueryParamFromLocation');
|
||||
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
||||
const intent = _profileQueryIntentFromLocation();
|
||||
global.switchToProfile = async () => false;
|
||||
(async () => {
|
||||
const savedLocalBefore = localStorage.getItem('hermes-webui-session');
|
||||
let profileSwitchCompleted = false;
|
||||
if (intent && intent.hasParam) {
|
||||
try {
|
||||
if (intent.valid) {
|
||||
if (typeof switchToProfile === 'function') {
|
||||
profileSwitchCompleted = await switchToProfile(intent.name) === true;
|
||||
if (profileSwitchCompleted && typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
} else {
|
||||
console.warn('[boot] ignored invalid profile query', intent.name);
|
||||
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[boot] profile query switch failed', e);
|
||||
}
|
||||
}
|
||||
const blocksSavedLocal = _profileQueryBlocksSavedLocalRestore(intent, null);
|
||||
if (blocksSavedLocal && profileSwitchCompleted && localStorage.getItem('hermes-webui-session') === savedLocalBefore) localStorage.removeItem('hermes-webui-session');
|
||||
console.log(JSON.stringify({
|
||||
blocksSavedLocal,
|
||||
profileSwitchCompleted,
|
||||
savedLocalAfter: localStorage.getItem('hermes-webui-session'),
|
||||
url: window.location.pathname + window.location.search + window.location.hash,
|
||||
historyCalls: window.history.calls,
|
||||
warns,
|
||||
}));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["blocksSavedLocal"] is True
|
||||
assert payload["profileSwitchCompleted"] is False
|
||||
assert payload["savedLocalAfter"] == "saved-local"
|
||||
assert payload["url"] == "/app/?profile=vops&q=hello&keep=1#frag"
|
||||
assert payload["historyCalls"] == []
|
||||
assert payload["warns"] == []
|
||||
|
||||
|
||||
def test_invalid_profile_query_warns_and_skips_switch():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: null,
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
const warns = [];
|
||||
console.warn = (...args) => { warns.push(args); };
|
||||
applyUrl('/app/?profile=../bad&q=hello&keep=1#frag');
|
||||
evalSession('_profileQueryIntentFromLocation');
|
||||
evalSession('_consumeProfileQueryParamFromLocation');
|
||||
const intent = _profileQueryIntentFromLocation();
|
||||
const switched = [];
|
||||
global.switchToProfile = async (name) => { switched.push(name); };
|
||||
(async () => {
|
||||
if (intent && intent.hasParam) {
|
||||
try {
|
||||
if (intent.valid) {
|
||||
if (typeof switchToProfile === 'function') await switchToProfile(intent.name);
|
||||
} else {
|
||||
console.warn('[boot] ignored invalid profile query', intent.name);
|
||||
if (typeof _consumeProfileQueryParamFromLocation === 'function') _consumeProfileQueryParamFromLocation();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[boot] profile query switch failed', e);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
intent,
|
||||
switched,
|
||||
warns,
|
||||
url: window.location.pathname + window.location.search + window.location.hash,
|
||||
historyCalls: window.history.calls,
|
||||
}));
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["intent"] == {"hasParam": True, "valid": False, "name": "../bad"}
|
||||
assert payload["switched"] == []
|
||||
assert payload["warns"] == [["[boot] ignored invalid profile query", "../bad"]]
|
||||
assert payload["url"] == "/app/?q=hello&keep=1#frag"
|
||||
assert payload["historyCalls"] == [{"state": None, "title": "", "url": "/app/?q=hello&keep=1#frag"}]
|
||||
|
||||
|
||||
def test_prefill_cleanup_still_strips_q_prompt_and_send():
|
||||
source = _node_prelude() + """
|
||||
function applyUrl(rel) {
|
||||
const next = new URL(rel, 'https://example.test');
|
||||
window.location.href = next.href;
|
||||
window.location.pathname = next.pathname;
|
||||
window.location.search = next.search;
|
||||
window.location.hash = next.hash;
|
||||
}
|
||||
global.window = {
|
||||
location: {},
|
||||
history: {
|
||||
state: null,
|
||||
calls: [],
|
||||
replaceState(state, title, url) {
|
||||
this.calls.push({ state, title, url });
|
||||
this.state = state;
|
||||
applyUrl(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
global.document = { baseURI: 'https://example.test/app/' };
|
||||
applyUrl('/app/?q=hello&prompt=hi&send=1&keep=1#frag');
|
||||
evalSession('_consumeComposerPrefillParamsFromLocation');
|
||||
_consumeComposerPrefillParamsFromLocation();
|
||||
console.log(JSON.stringify({
|
||||
url: window.location.pathname + window.location.search + window.location.hash,
|
||||
historyCalls: window.history.calls,
|
||||
}));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["url"] == "/app/?keep=1#frag"
|
||||
assert payload["historyCalls"] == [{"state": None, "title": "", "url": "/app/?keep=1#frag"}]
|
||||
|
||||
|
||||
def test_profile_query_blocks_only_implicit_saved_local_restore():
|
||||
source = _node_prelude() + """
|
||||
evalBoot('_profileQueryBlocksSavedLocalRestore');
|
||||
global.localStorage = {
|
||||
store: { 'hermes-webui-session': 'saved-local' },
|
||||
getItem(key) {
|
||||
return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
this.store[key] = String(value);
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this.store[key];
|
||||
}
|
||||
};
|
||||
const validProfile = { hasParam: true, valid: true };
|
||||
const invalidProfile = { hasParam: true, valid: false };
|
||||
const blocksImplicit = _profileQueryBlocksSavedLocalRestore(validProfile, null);
|
||||
if (blocksImplicit) localStorage.removeItem('hermes-webui-session');
|
||||
const implicitAfter = localStorage.getItem('hermes-webui-session');
|
||||
localStorage.setItem('hermes-webui-session', 'saved-local');
|
||||
const allowsExplicit = _profileQueryBlocksSavedLocalRestore(validProfile, 'session-123');
|
||||
if (allowsExplicit) localStorage.removeItem('hermes-webui-session');
|
||||
const explicitAfter = localStorage.getItem('hermes-webui-session');
|
||||
console.log(JSON.stringify({
|
||||
blocksImplicit,
|
||||
allowsExplicit,
|
||||
ignoresInvalid: _profileQueryBlocksSavedLocalRestore(invalidProfile, null),
|
||||
implicitAfter,
|
||||
explicitAfter,
|
||||
}));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload == {
|
||||
"blocksImplicit": True,
|
||||
"allowsExplicit": False,
|
||||
"ignoresInvalid": False,
|
||||
"implicitAfter": None,
|
||||
"explicitAfter": "saved-local",
|
||||
}
|
||||
|
||||
|
||||
def test_profile_transition_reasoning_refresh_hides_stale_chip_until_destination_resolves():
|
||||
source = f"""
|
||||
const uiSrc = {UI_JS!r};
|
||||
function extractFunc(name) {{
|
||||
const re = new RegExp('function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = uiSrc.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = uiSrc.indexOf('{{', start), depth = 1; i++;
|
||||
while (depth > 0 && i < uiSrc.length) {{
|
||||
if (uiSrc[i] === '{{') depth++;
|
||||
else if (uiSrc[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return uiSrc.slice(start, i);
|
||||
}}
|
||||
function makeEl() {{
|
||||
return {{
|
||||
style: {{}}, classList: {{ toggle(){{}}, add(){{}}, remove(){{}} }},
|
||||
setAttribute(){{}}, querySelectorAll(){{ return []; }},
|
||||
}};
|
||||
}}
|
||||
const els = {{
|
||||
composerReasoningWrap: makeEl(), composerReasoningLabel: makeEl(),
|
||||
composerReasoningChip: makeEl(), composerMobileReasoningAction: makeEl(),
|
||||
}};
|
||||
global.$ = id => els[id] || null;
|
||||
global.S = {{ session: {{ model: 'gpt-5', model_provider: 'openai' }} }};
|
||||
global._highlightReasoningOption = () => {{}};
|
||||
global._applyReasoningOptions = () => {{}};
|
||||
global._reasoningEffortQuery = () => '?model=gpt-5';
|
||||
const pending = [];
|
||||
let calls = 0;
|
||||
global.api = () => {{
|
||||
calls++;
|
||||
return {{ then(ok) {{ pending.push(ok); return {{ catch() {{}} }}; }} }};
|
||||
}};
|
||||
var _currentReasoningEffort = 'low';
|
||||
var _currentReasoningEffortsSupported = ['low', 'high'];
|
||||
var _lastReasoningFetchKey = '?model=gpt-5';
|
||||
var _reasoningFetchSeq = 7;
|
||||
eval(extractFunc('_normalizeReasoningEffort'));
|
||||
eval(extractFunc('_formatReasoningEffortLabel'));
|
||||
eval(extractFunc('_applyReasoningChip'));
|
||||
eval(extractFunc('fetchReasoningChip'));
|
||||
eval(extractFunc('refreshProfileTransitionReasoningChip'));
|
||||
fetchReasoningChip();
|
||||
refreshProfileTransitionReasoningChip();
|
||||
const beforeDestination = {{
|
||||
effort: _currentReasoningEffort,
|
||||
cachedKey: _lastReasoningFetchKey,
|
||||
wrapDisplay: els.composerReasoningWrap.style.display,
|
||||
calls,
|
||||
}};
|
||||
pending[0]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }});
|
||||
const afterPreviousResponse = {{ effort: _currentReasoningEffort, wrapDisplay: els.composerReasoningWrap.style.display }};
|
||||
pending[1]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }});
|
||||
console.log(JSON.stringify({{ beforeDestination, afterPreviousResponse, afterDestination: _currentReasoningEffort, calls }}));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["beforeDestination"] == {
|
||||
"effort": "",
|
||||
"cachedKey": "?model=gpt-5",
|
||||
"wrapDisplay": "none",
|
||||
"calls": 2,
|
||||
}
|
||||
assert payload["afterPreviousResponse"] == {"effort": "", "wrapDisplay": "none"}
|
||||
assert payload["afterDestination"] == "high"
|
||||
assert payload["calls"] == 2
|
||||
assert "refreshProfileTransitionReasoningChip" in PANELS_JS
|
||||
assert PANELS_JS.index("refreshProfileTransitionReasoningChip") > PANELS_JS.index("S.activeProfile = data.active || name")
|
||||
background = PANELS_JS[PANELS_JS.index("function _refreshProfileSwitchBackground"):PANELS_JS.index("async function loadProfilesPanel")]
|
||||
for refresh in (
|
||||
"_ensureComposerControlVisibilityState",
|
||||
"_setComposerControlOrder",
|
||||
"_renderComposerControlChips",
|
||||
"_renderComposerSituationalControlChips",
|
||||
"_applyComposerFooterVisibilitySettings",
|
||||
"_applyTitlebarProfileVisibility",
|
||||
):
|
||||
assert refresh in background
|
||||
|
||||
|
||||
def test_profile_transitions_fetch_destination_reasoning_after_hiding_stale_chip():
|
||||
source = f"""
|
||||
const uiSrc = {UI_JS!r};
|
||||
const panelsSrc = {PANELS_JS!r};
|
||||
const sessionsSrc = {SESSIONS_JS!r};
|
||||
function extractFunc(src, name) {{
|
||||
const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start), depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
function makeEl() {{ return {{ style: {{}}, disabled: false, classList: {{ add(){{}}, remove(){{}}, toggle(){{}} }}, setAttribute(){{}}, querySelectorAll(){{ return []; }} }}; }}
|
||||
const els = {{ composerReasoningWrap: makeEl(), composerReasoningLabel: makeEl(), composerReasoningChip: makeEl(), composerMobileReasoningAction: makeEl() }};
|
||||
global.$ = id => els[id] || null;
|
||||
global.window = {{}};
|
||||
global.document = {{ title: '' }};
|
||||
global.localStorage = {{ removeItem(){{}} }};
|
||||
global.S = {{ activeProfile: 'default', activeProfileIsDefault: true, session: null, messages: [] }};
|
||||
global._highlightReasoningOption = () => {{}};
|
||||
global._applyReasoningOptions = () => {{}};
|
||||
global._applyModelToDropdown = model => model;
|
||||
global._modelStateForSelect = (_, model) => ({{ model, model_provider: 'openai' }});
|
||||
global.renderSessionList = async () => {{}};
|
||||
global.startGatewaySSE = () => {{}};
|
||||
global.showToast = () => {{}};
|
||||
global.t = value => value;
|
||||
global.assistantDisplayName = () => 'Hermes';
|
||||
global._profileSwitchPanelLoad = async () => {{}};
|
||||
global._refreshProfileSwitchBackground = () => {{}};
|
||||
var _profileSwitchGeneration = 0;
|
||||
var _skillsData = null, _workspaceList = null;
|
||||
var _currentReasoningEffort = 'low';
|
||||
var _currentReasoningEffortsSupported = ['low', 'high'];
|
||||
var _profileTransitionReasoningContext = null;
|
||||
var _lastReasoningFetchKey = null;
|
||||
var _reasoningFetchSeq = 0;
|
||||
eval(extractFunc(uiSrc, '_normalizeReasoningEffort'));
|
||||
eval(extractFunc(uiSrc, '_formatReasoningEffortLabel'));
|
||||
eval(extractFunc(uiSrc, '_reasoningEffortContext'));
|
||||
eval(extractFunc(uiSrc, '_reasoningEffortQuery'));
|
||||
eval(extractFunc(uiSrc, '_applyReasoningChip'));
|
||||
eval(extractFunc(uiSrc, 'fetchReasoningChip'));
|
||||
eval(extractFunc(uiSrc, 'refreshProfileTransitionReasoningChip'));
|
||||
eval(extractFunc(uiSrc, 'syncTopbar'));
|
||||
eval(extractFunc(panelsSrc, 'switchToProfile'));
|
||||
eval(extractFunc(sessionsSrc, '_switchProfileForSessionLoad'));
|
||||
const pending = [];
|
||||
const reasoningUrls = [];
|
||||
global.api = (url) => {{
|
||||
if (url === '/api/profile/switch') return Promise.resolve({{ active: 'vops', is_default: false, default_model: 'gpt-high', default_model_provider: 'openai' }});
|
||||
if (url.startsWith('/api/reasoning')) {{
|
||||
reasoningUrls.push(url);
|
||||
return {{ then(ok) {{ pending.push(ok); return {{ catch() {{}} }}; }} }};
|
||||
}}
|
||||
throw new Error('unexpected API ' + url);
|
||||
}};
|
||||
fetchReasoningChip();
|
||||
(async () => {{
|
||||
await switchToProfile('vops');
|
||||
const blankBoot = {{ hidden: els.composerReasoningWrap.style.display, urls: reasoningUrls.slice() }};
|
||||
pending[0]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }});
|
||||
const blankBootAfterOld = _currentReasoningEffort;
|
||||
pending[1]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }});
|
||||
const blankBootAfterNew = _currentReasoningEffort;
|
||||
S.activeProfile = 'default'; S.activeProfileIsDefault = true;
|
||||
S.session = {{ model: 'old-model', model_provider: 'old-provider', profile: 'default' }};
|
||||
_currentReasoningEffort = 'low'; _currentReasoningEffortsSupported = ['low', 'high']; _profileTransitionReasoningContext = null; _lastReasoningFetchKey = null;
|
||||
fetchReasoningChip();
|
||||
await _switchProfileForSessionLoad('vops');
|
||||
const directLoad = {{ hidden: els.composerReasoningWrap.style.display, urls: reasoningUrls.slice(2) }};
|
||||
pending[2]({{ reasoning_effort: 'low', supported_efforts: ['low', 'high'] }});
|
||||
const directLoadAfterOld = _currentReasoningEffort;
|
||||
pending[3]({{ reasoning_effort: 'high', supported_efforts: ['low', 'high'] }});
|
||||
console.log(JSON.stringify({{ blankBoot, blankBootAfterOld, blankBootAfterNew, directLoad, directLoadAfterOld, directLoadAfterNew: _currentReasoningEffort }}));
|
||||
}})().catch(err => {{ console.error(err); process.exit(1); }});
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["blankBoot"] == {
|
||||
"hidden": "none",
|
||||
"urls": ["/api/reasoning", "/api/reasoning?model=gpt-high&provider=openai"],
|
||||
}
|
||||
assert payload["blankBootAfterOld"] == ""
|
||||
assert payload["blankBootAfterNew"] == "high"
|
||||
assert payload["directLoad"] == {
|
||||
"hidden": "none",
|
||||
"urls": ["/api/reasoning?model=old-model&provider=old-provider", "/api/reasoning?model=gpt-high&provider=openai"],
|
||||
}
|
||||
assert payload["directLoadAfterOld"] == ""
|
||||
assert payload["directLoadAfterNew"] == "high"
|
||||
|
||||
|
||||
def test_blank_profile_transition_context_clears_before_explicit_model_change():
|
||||
source = f"""
|
||||
const uiSrc = {UI_JS!r};
|
||||
const bootSrc = {BOOT_JS!r};
|
||||
function extractFunc(src, name) {{
|
||||
const re = new RegExp('(?:async\\\\s+)?function\\\\s+' + name + '\\\\s*\\\\(');
|
||||
const start = src.search(re);
|
||||
if (start < 0) throw new Error(name + ' not found');
|
||||
let i = src.indexOf('{{', start), depth = 1; i++;
|
||||
while (depth > 0 && i < src.length) {{
|
||||
if (src[i] === '{{') depth++;
|
||||
else if (src[i] === '}}') depth--;
|
||||
i++;
|
||||
}}
|
||||
return src.slice(start, i);
|
||||
}}
|
||||
function makeEl() {{ return {{ style: {{}}, classList: {{ toggle(){{}} }}, setAttribute(){{}}, querySelectorAll(){{ return []; }} }}; }}
|
||||
const els = {{
|
||||
modelSelect: {{ value: 'claude-sonnet' }},
|
||||
composerReasoningWrap: makeEl(),
|
||||
composerReasoningLabel: makeEl(),
|
||||
composerReasoningChip: makeEl(),
|
||||
composerMobileReasoningAction: makeEl()
|
||||
}};
|
||||
global.$ = id => els[id] || null;
|
||||
global.S = {{ activeProfile: 'vops', session: null }};
|
||||
global._highlightReasoningOption = () => {{}};
|
||||
global._applyReasoningOptions = () => {{}};
|
||||
global._modelStateForSelect = (_, model) => ({{ model, model_provider: 'anthropic' }});
|
||||
var _currentReasoningEffort = 'high';
|
||||
var _currentReasoningEffortsSupported = ['low', 'high'];
|
||||
var _profileTransitionReasoningContext = {{ profile: 'vops', model: 'gpt-high', provider: 'openai' }};
|
||||
var _lastReasoningFetchKey = '?model=gpt-high&provider=openai';
|
||||
var _reasoningFetchSeq = 1;
|
||||
const urls = [];
|
||||
global.api = (url) => {{
|
||||
urls.push(url);
|
||||
return Promise.resolve({{ reasoning_effort: 'medium', supported_efforts: ['medium'] }});
|
||||
}};
|
||||
eval(extractFunc(uiSrc, '_normalizeReasoningEffort'));
|
||||
eval(extractFunc(uiSrc, '_formatReasoningEffortLabel'));
|
||||
eval(extractFunc(uiSrc, '_reasoningEffortContext'));
|
||||
eval(extractFunc(uiSrc, '_reasoningEffortQuery'));
|
||||
eval(extractFunc(uiSrc, '_applyReasoningChip'));
|
||||
eval(extractFunc(uiSrc, 'fetchReasoningChip'));
|
||||
eval(extractFunc(uiSrc, 'refreshProfileTransitionReasoningChip'));
|
||||
eval(extractFunc(uiSrc, 'clearProfileTransitionReasoningContext'));
|
||||
eval(extractFunc(uiSrc, 'syncReasoningChip'));
|
||||
clearProfileTransitionReasoningContext();
|
||||
syncReasoningChip();
|
||||
const handler = bootSrc.slice(bootSrc.indexOf("$('modelSelect').onchange=async()=>"), bootSrc.indexOf("$('msg').addEventListener", bootSrc.indexOf("$('modelSelect').onchange=async()=>")));
|
||||
const clearPos = handler.indexOf("clearProfileTransitionReasoningContext");
|
||||
const rememberBlankPos = handler.indexOf("_rememberEmptyComposerModelOverride");
|
||||
const noSessionSyncPos = handler.indexOf("syncReasoningChip()", rememberBlankPos);
|
||||
console.log(JSON.stringify({{
|
||||
urls,
|
||||
context: _profileTransitionReasoningContext,
|
||||
lastKey: _lastReasoningFetchKey,
|
||||
clearPos,
|
||||
noSessionSyncPos
|
||||
}}));
|
||||
"""
|
||||
payload = json.loads(_run_node(source))
|
||||
assert payload["urls"] == ["/api/reasoning?model=claude-sonnet&provider=anthropic"]
|
||||
assert payload["context"] is None
|
||||
assert payload["lastKey"] == "?model=claude-sonnet&provider=anthropic"
|
||||
assert payload["clearPos"] >= 0
|
||||
assert payload["clearPos"] < payload["noSessionSyncPos"]
|
||||
@@ -16,12 +16,9 @@ def test_boot_call_before_session_load():
|
||||
"""fetchReasoningChip() should be called before session load in boot sequence."""
|
||||
with open("static/boot.js") as f:
|
||||
src = f.read()
|
||||
# Find the boot session load; URL-anchored tabs may prefer a URL session id
|
||||
# before falling back to the stored session id.
|
||||
boot_marker = "localStorage.getItem('hermes-webui-session')"
|
||||
boot_marker = "await loadSession(saved, {preserveActiveInput:true});"
|
||||
boot_pos = src.index(boot_marker)
|
||||
fetch_pos = src.index("fetchReasoningChip()")
|
||||
# fetchReasoningChip must be called just before the saved session load
|
||||
fetch_pos = src.index("fetchReasoningChip()", src.index("_profileQueryIntentFromLocation"))
|
||||
assert fetch_pos < boot_pos, \
|
||||
"fetchReasoningChip() should be called before saved session load in boot.js"
|
||||
|
||||
@@ -60,12 +57,12 @@ def test_ui_js_passes_model_context_to_reasoning_api():
|
||||
# the query into a local `key` first (for the in-flight storm + stale-success
|
||||
# guards), so accept either the inlined form or the captured-key form — both
|
||||
# pass _reasoningEffortQuery()'s output to the endpoint.
|
||||
fetch_match = re.search(r"function fetchReasoningChip\(\)\{(.+?)\n\}", src, re.DOTALL)
|
||||
fetch_match = re.search(r"function fetchReasoningChip\([^)]*\)\{(.+?)\n\}", src, re.DOTALL)
|
||||
assert fetch_match, "fetchReasoningChip function must exist"
|
||||
fetch_body = fetch_match.group(1)
|
||||
inlined = "api('/api/reasoning'+_reasoningEffortQuery())" in src
|
||||
captured = (
|
||||
re.search(r"const\s+key\s*=\s*_reasoningEffortQuery\(\)", fetch_body)
|
||||
"_reasoningEffortQuery()" in fetch_body
|
||||
and "api('/api/reasoning'+key)" in fetch_body
|
||||
)
|
||||
assert inlined or captured, (
|
||||
@@ -79,7 +76,7 @@ def test_fetchReasoningChip_calls_apply():
|
||||
with open("static/ui.js") as f:
|
||||
src = f.read()
|
||||
# Find fetchReasoningChip function
|
||||
func_match = re.search(r"function fetchReasoningChip\(\)\{(.+?)\}", src, re.DOTALL)
|
||||
func_match = re.search(r"function fetchReasoningChip\([^)]*\)\{(.+?)\}", src, re.DOTALL)
|
||||
assert func_match, "fetchReasoningChip function must exist"
|
||||
func_body = func_match.group(1)
|
||||
assert "_applyReasoningChip" in func_body, \
|
||||
|
||||
@@ -81,6 +81,7 @@ function extractFunc(name) {
|
||||
// declare them in this eval scope so the extracted functions can see them.
|
||||
var _currentReasoningEffort = null;
|
||||
var _currentReasoningEffortsSupported = null;
|
||||
var _profileTransitionReasoningContext = null;
|
||||
var _lastReasoningFetchKey = null;
|
||||
var _reasoningFetchSeq = 0;
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class TestSwitchWiring:
|
||||
# (Codex gate #4662). The skeletons shown up front already provide the
|
||||
# immediate cross-surface feedback.
|
||||
body = _switch_body()
|
||||
guard = "if (_switchGen !== _profileSwitchGeneration) return;"
|
||||
guard = "if (_switchGen !== _profileSwitchGeneration) return false;"
|
||||
# In the non-sessionInProgress branch, the loadDir('.') call must come
|
||||
# after an occurrence of the guard.
|
||||
dir_idx = body.index("const dirLoad = loadDir('.');")
|
||||
@@ -88,7 +88,7 @@ class TestSwitchWiring:
|
||||
body = _switch_body()
|
||||
head = body[: _show_session_skeleton_call_idx(body)]
|
||||
assert "name === S.activeProfile" in head, "missing no-op self-switch early-return"
|
||||
assert "return;" in head
|
||||
assert "return true;" in head
|
||||
|
||||
def test_dismisses_rename_and_menu_before_skeleton(self):
|
||||
# Opus gate #4662: renderSessionListFromCache() early-returns while
|
||||
@@ -128,7 +128,7 @@ class TestSwitchWiring:
|
||||
# rapid second switch could have its workspace skeleton cleared / a stale
|
||||
# toast popped by the slower earlier switch. Mirror the no-messages guard.
|
||||
body = _switch_body()
|
||||
guard = "if (_switchGen !== _profileSwitchGeneration) return;"
|
||||
guard = "if (_switchGen !== _profileSwitchGeneration) return false;"
|
||||
# Inside the sessionInProgress branch: locate its "await renderSessionList()"
|
||||
# then the profile_switched_new_conversation toast; a guard must sit between.
|
||||
new_toast_idx = body.index("profile_switched_new_conversation")
|
||||
|
||||
@@ -1443,7 +1443,7 @@ def test_reasoning_chip_updates_desktop_and_mobile_controls():
|
||||
"""Reasoning chip sync should keep both footer and mobile overflow labels current."""
|
||||
ui_js = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
chip_start = ui_js.index("function _applyReasoningChip(eff)")
|
||||
chip_end = ui_js.index("function fetchReasoningChip()", chip_start)
|
||||
chip_end = ui_js.index("function fetchReasoningChip(", chip_start)
|
||||
chip_body = ui_js[chip_start:chip_end]
|
||||
for expected in (
|
||||
"composerReasoningWrap",
|
||||
|
||||
@@ -49,6 +49,18 @@ def test_onboarding_uses_i18n_helpers():
|
||||
assert "onboarding_title: 'Bienvenido a Hermes Web UI'" in i18n
|
||||
|
||||
|
||||
def test_onboarding_provider_notice_uses_i18n_key():
|
||||
js = read("static/onboarding.js")
|
||||
py = read("api/onboarding.py")
|
||||
assert '"provider_note_key": note_key' in py
|
||||
assert '"provider_note_args": note_args' in py
|
||||
assert "function _localizedOnboardingProviderNote(system)" in js
|
||||
assert "Array.isArray(system&&system.provider_note_args)" in js
|
||||
assert "t(key,...args)" in js
|
||||
assert "!/\\{\\d+\\}/.test(localized)" in js
|
||||
assert "system.provider_note|| (setupOk?" not in js
|
||||
|
||||
|
||||
def test_bootstrap_script_contains_official_installer_and_windows_guard():
|
||||
src = read("bootstrap.py")
|
||||
assert (
|
||||
|
||||
@@ -68,6 +68,7 @@ global.document = {
|
||||
};
|
||||
global.$ = id => els[id] || null;
|
||||
global.api = () => ({ then: () => ({ catch: () => {} }), catch: () => {} });
|
||||
var _profileTransitionReasoningContext = null;
|
||||
|
||||
function extractFunc(name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\(');
|
||||
|
||||
@@ -93,6 +93,11 @@ def test_russian_locale_includes_representative_translations():
|
||||
"profile_clone_label: '\u0421\u043a\u043e\u043f\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e \u0438\u0437 \u0430\u043a\u0442\u0438\u0432\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0444\u0438\u043b\u044f'",
|
||||
"profile_base_url_placeholder: '\u0411\u0430\u0437\u043e\u0432\u044b\u0439 URL \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u043d\u0430\u043f\u0440\u0438\u043c\u0435\u0440 http://localhost:11434\u0029'",
|
||||
"profile_api_key_placeholder: 'API-\u043a\u043b\u044e\u0447 \u0028\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e\u0029'",
|
||||
"mcp_tools_title: '\u0418\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442\u044b MCP'",
|
||||
"goal_status_none: '\u0410\u043a\u0442\u0438\u0432\u043d\u043e\u0439 \u0446\u0435\u043b\u0438 \u043d\u0435\u0442. \u0417\u0430\u0434\u0430\u0439\u0442\u0435 \u0435\u0451 \u043a\u043e\u043c\u0430\u043d\u0434\u043e\u0439 /goal <\u0442\u0435\u043a\u0441\u0442>.'",
|
||||
"settings_heading_title: '\u0426\u0435\u043d\u0442\u0440 \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f'",
|
||||
"providers_tab_title: '\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b'",
|
||||
"wiki_not_configured: 'Wiki \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430'",
|
||||
]
|
||||
for entry in expected:
|
||||
assert entry in src
|
||||
@@ -100,7 +105,7 @@ def test_russian_locale_includes_representative_translations():
|
||||
|
||||
def test_russian_locale_covers_english_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
key_pattern = re.compile(r"^\s+([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
en_keys = set(key_pattern.findall(extract_locale_block(src, "en")))
|
||||
ru_keys = set(key_pattern.findall(extract_locale_block(src, "ru")))
|
||||
|
||||
@@ -110,7 +115,13 @@ def test_russian_locale_covers_english_keys():
|
||||
|
||||
def test_russian_locale_has_no_duplicate_keys():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
key_pattern = re.compile(r"^\s{4}([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
key_pattern = re.compile(r"^\s+([a-zA-Z0-9_]+):", re.MULTILINE)
|
||||
keys = key_pattern.findall(extract_locale_block(src, "ru"))
|
||||
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
|
||||
assert not duplicates, f"Russian locale has duplicate keys: {duplicates}"
|
||||
|
||||
|
||||
def test_russian_locale_has_no_cjk_fallback_text():
|
||||
src = read(REPO / "static" / "i18n.js")
|
||||
ru_block = extract_locale_block(src, "ru")
|
||||
assert not re.search(r"[\u3400-\u9fff\u3040-\u30ff]", ru_block)
|
||||
@@ -181,6 +181,8 @@ class TestStatusFromRuntimeOAuth:
|
||||
assert result["provider_configured"] is True
|
||||
assert result["provider_ready"] is False
|
||||
assert result["setup_state"] == "provider_incomplete"
|
||||
assert result["provider_note_key"] == "onboarding_notice_provider_auth_required"
|
||||
assert result["provider_note_args"] == ["copilot"]
|
||||
|
||||
def test_oauth_incomplete_note_mentions_hermes_auth(self, tmp_path):
|
||||
"""When OAuth provider is incomplete, note should mention hermes auth/model."""
|
||||
@@ -198,11 +200,20 @@ class TestStatusFromRuntimeOAuth:
|
||||
f"Note misleadingly mentions 'API key' for OAuth provider: {note!r}"
|
||||
)
|
||||
|
||||
def test_custom_provider_missing_base_url_gets_specific_note_key(self, tmp_path):
|
||||
"""Custom provider setup must not collapse into the generic setup notice."""
|
||||
result = self._call("custom", "local-model", tmp_path)
|
||||
assert result["provider_ready"] is False
|
||||
assert result["provider_note_key"] == "onboarding_notice_custom_base_url_required"
|
||||
assert result["provider_note_args"] == []
|
||||
assert "base URL" in result["provider_note"]
|
||||
|
||||
def test_standard_provider_incomplete_note_still_says_api_key(self, tmp_path):
|
||||
"""For a standard API-key provider (openrouter), note should still say API key."""
|
||||
# openrouter with no .env
|
||||
result = self._call("openrouter", "anthropic/claude-sonnet-4.6", tmp_path)
|
||||
assert result["provider_ready"] is False
|
||||
assert result["provider_note_key"] == "onboarding_notice_provider_api_key_required"
|
||||
note = result["provider_note"]
|
||||
assert "API key" in note, (
|
||||
f"Expected 'API key' in note for openrouter, got: {note!r}"
|
||||
|
||||
@@ -30,6 +30,18 @@ import pathlib
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
I18N_JS = (REPO / "static" / "i18n.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _html_tag_by_id(element_id: str) -> str:
|
||||
marker = f'id="{element_id}"'
|
||||
idx = INDEX_HTML.find(marker)
|
||||
assert idx > 0, f"{element_id} not found in index.html"
|
||||
start = INDEX_HTML.rfind("<button", 0, idx)
|
||||
end = INDEX_HTML.find(">", idx)
|
||||
assert start >= 0 and end > idx, f"{element_id} button tag not found"
|
||||
return INDEX_HTML[start : end + 1]
|
||||
|
||||
|
||||
# ── 1. syncWorkspacePanelState preserves browse mode without a session ──────
|
||||
@@ -136,3 +148,35 @@ class TestToggleStaysEnabledWithProfileWorkspace:
|
||||
"early-return guard so users can open the panel via the toggle "
|
||||
"button when a profile workspace is configured"
|
||||
)
|
||||
|
||||
|
||||
class TestWorkspacePanelStatefulLocalization:
|
||||
def test_locale_pass_resyncs_stateful_panel_tooltips(self):
|
||||
"""Locale re-stamping must finish by restoring open/closed panel labels."""
|
||||
assert "if (typeof syncWorkspacePanelUI === 'function') syncWorkspacePanelUI();" in I18N_JS
|
||||
|
||||
def test_stateful_toggle_buttons_do_not_carry_static_title_keys(self):
|
||||
"""The show/hide tooltip belongs to syncWorkspacePanelUI(), not static DOM i18n."""
|
||||
for element_id in ("btnWorkspacePanelToggle", "btnWorkspacePanelEdgeToggle"):
|
||||
tag = _html_tag_by_id(element_id)
|
||||
assert 'data-i18n-title="workspace_panel_show"' not in tag
|
||||
assert 'data-i18n-title="workspace_panel_hide"' not in tag
|
||||
|
||||
def test_stateful_toggle_buttons_do_not_carry_static_aria_keys(self):
|
||||
"""The final accessible label should match the current show/hide action."""
|
||||
for element_id in ("btnWorkspacePanelToggle", "btnWorkspacePanelEdgeToggle"):
|
||||
tag = _html_tag_by_id(element_id)
|
||||
assert 'data-i18n-aria-label="workspace_panel_toggle"' not in tag
|
||||
assert 'data-i18n-aria-label="workspace_panel_show"' not in tag
|
||||
assert 'data-i18n-aria-label="workspace_panel_hide"' not in tag
|
||||
assert "toggleBtn.setAttribute('aria-label', label);" in BOOT_JS
|
||||
assert "edgeToggleBtn.setAttribute('aria-label', label);" in BOOT_JS
|
||||
|
||||
def test_clear_preview_accessible_label_matches_stateful_tooltip(self):
|
||||
"""The close/preview action should expose the same localized label everywhere."""
|
||||
idx = BOOT_JS.find("const clearBtn=$('btnClearPreview');")
|
||||
assert idx > 0, "btnClearPreview sync block not found"
|
||||
body = BOOT_JS[idx:idx + 600]
|
||||
assert "const label=hasPreview?" in body
|
||||
assert "_setButtonTooltip(clearBtn, label);" in body
|
||||
assert "clearBtn.setAttribute('aria-label', label);" in body
|
||||
Reference in new issue
Block a user