diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e938441..98262dff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2528** (closes #2518) — Guard New Conversation creation while a previous `/api/session/new` request is still in flight, so cold model/provider catalog resolution gives immediate pending feedback and rapid repeated clicks reuse the same create request instead of enqueueing duplicate blank sessions. + ## [v0.51.89] — 2026-05-18 — Release BM (stage-382 — 6-PR full sweep batch — runtime adapter approval/clarify seam + SOUL.md memory panel + #1855 resolve_model_provider fast-path + PWA sidebar spinner fix + /model active-provider preference + contributor contract docs index) ### Changed diff --git a/docs/pr-media/2518/new-conversation-idle.png b/docs/pr-media/2518/new-conversation-idle.png new file mode 100644 index 00000000..a7c63044 Binary files /dev/null and b/docs/pr-media/2518/new-conversation-idle.png differ diff --git a/docs/pr-media/2518/new-conversation-pending.png b/docs/pr-media/2518/new-conversation-pending.png new file mode 100644 index 00000000..50f9ca8b Binary files /dev/null and b/docs/pr-media/2518/new-conversation-pending.png differ diff --git a/static/i18n.js b/static/i18n.js index e1ad98e3..256eeab6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -206,6 +206,7 @@ const LOCALES = { switched_workspace: 'Switched to workspace: ', workspace_switch_failed: 'Workspace switch failed: ', new_session: 'New session created', + new_session_creating: 'Creating new conversation…', compressing: 'Requesting context compression...', compress_running_label: 'Compressing', compress_complete_label: 'Compression complete', @@ -1419,6 +1420,7 @@ const LOCALES = { switched_workspace: 'Passato al workspace: ', workspace_switch_failed: 'Cambio workspace fallito: ', new_session: 'Nuova sessione creata', + new_session_creating: 'Creazione nuova conversazione…', compressing: 'Richiesta compressione contesto...', compress_running_label: 'Compressione in corso', compress_complete_label: 'Compressione completata', @@ -2624,6 +2626,7 @@ const LOCALES = { switched_workspace: 'ワークスペースを切り替えました: ', workspace_switch_failed: 'ワークスペース切替失敗: ', new_session: '新しいセッションを作成しました', + new_session_creating: '新しい会話を作成中…', compressing: 'コンテキスト圧縮を要求中...', compress_running_label: '圧縮中', compress_complete_label: '圧縮完了', @@ -3794,6 +3797,7 @@ const LOCALES = { switched_workspace: 'Переключено на рабочее пространство: ', workspace_switch_failed: 'Не удалось переключить рабочее пространство: ', new_session: 'Новая сессия создана', + new_session_creating: 'Создаём новую беседу…', compressing: 'Запрашиваю сжатие контекста...', token_usage_on: 'Отображение токенов включено', usage_personality_none: 'none', // TODO: translate @@ -4970,6 +4974,7 @@ const LOCALES = { switched_workspace: 'Se cambió al espacio de trabajo: ', workspace_switch_failed: 'Error al cambiar de espacio de trabajo: ', new_session: 'Nueva sesión creada', + new_session_creating: 'Creando nueva conversación…', compressing: 'Solicitando compresión del contexto...', compress_running_label: 'Comprimiendo', compress_complete_label: 'Compresión completa', @@ -6088,6 +6093,7 @@ const LOCALES = { switched_workspace: 'Gewechselt zu Workspace: ', workspace_switch_failed: 'Workspace-Wechsel fehlgeschlagen: ', new_session: 'Neue Sitzung erstellt', + new_session_creating: 'Neue Unterhaltung wird erstellt…', compressing: 'Kontext-Komprimierung wird angefordert...', compress_running_label: 'Komprimierung', compress_complete_label: 'Komprimierung abgeschlossen', @@ -7257,6 +7263,7 @@ const LOCALES = { switched_workspace: '已切换工作区:', workspace_switch_failed: '工作区切换失败:', new_session: '已新建会话', + new_session_creating: '正在创建新会话…', compressing: '正在请求压缩上下文...', compress_running_label: '压缩中', compress_complete_label: '压缩完成', @@ -8363,6 +8370,7 @@ const LOCALES = { switched_workspace: '\u5df2\u5207\u63db\u5de5\u4f5c\u5340\uff1a', workspace_switch_failed: '\u5de5\u4f5c\u5340\u5207\u63db\u5931\u6557\uff1a', new_session: '\u5df2\u65b0\u5efa\u6703\u8a71', + new_session_creating: '正在建立新對話…', compressing: '\u6b63\u5728\u8981\u6c42\u58d3\u7e2e\u4e0a\u4e0b\u6587...', compress_running_label: '\u58d3\u7e2e\u4e2d', compress_complete_label: '\u58d3\u7e2e\u5b8c\u6210', @@ -9560,6 +9568,7 @@ const LOCALES = { switched_workspace: 'Trocado para workspace: ', workspace_switch_failed: 'Falha ao trocar workspace: ', new_session: 'Nova sessão criada', + new_session_creating: 'Criando nova conversa…', compressing: 'Solicitando compressão de contexto...', compress_running_label: 'Comprimindo', compress_complete_label: 'Compressão completa', @@ -10653,6 +10662,7 @@ const LOCALES = { switched_workspace: 'Switched to workspace: ', workspace_switch_failed: 'Workspace switch failed: ', new_session: '새 세션을 만들었습니다', + new_session_creating: '새 대화를 만드는 중…', compressing: 'Requesting context compression...', compress_running_label: 'Compressing', compress_complete_label: 'Compression complete', @@ -11850,6 +11860,7 @@ const LOCALES = { switched_workspace: 'Passé à l\'espace de travail :', workspace_switch_failed: 'Échec du changement d\'espace de travail :', new_session: 'Nouvelle session créée', + new_session_creating: 'Création d’une nouvelle conversation…', compressing: 'Demande de compression de contexte...', compress_running_label: 'Compression', compress_complete_label: 'Compression terminée', diff --git a/static/sessions.js b/static/sessions.js index 6d333ee4..f5e7c7b0 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -417,74 +417,104 @@ function _markPollingCompletionUnreadTransitions(sessions) { } } +let _newSessionInFlight=null; +const _newSessionPendingText=()=>t('new_session_creating')||'Creating new conversation…'; +function _setNewSessionPending(pending){ + const btn=$('btnNewChat'); + if(btn){ + btn.disabled=!!pending; + btn.setAttribute('aria-busy',pending?'true':'false'); + } + const statusEl=$('composerStatus'); + const pendingText=_newSessionPendingText(); + if(pending){ + setComposerStatus(pendingText); + }else if(statusEl&&statusEl.textContent===pendingText){ + setComposerStatus(''); + } +} + async function newSession(flash, options={}){ - updateQueueBadge(); - S.toolCalls=[]; - clearLiveToolCards(); - // One-shot profile-switch workspace: applied to the first new session after a profile - // switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the - // persistent boot/settings default) is not consumed and remains available for the - // blank-page display on all subsequent returns to the empty state (#823). - const switchWs=S._profileSwitchWorkspace; - S._profileSwitchWorkspace=null; - const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null); - // Use the saved default model for new sessions (#872). The user's saved - // default_model (from Settings) takes priority over the chat-header dropdown - // value, which reflects the *previous* session's model. Fall back to the - // dropdown value only when no default_model is configured. - const modelSel=$('modelSelect'); - const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||''; - let defaultApplied=false; - if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){ - defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null); + if(_newSessionInFlight){ + if(typeof showToast==='function') showToast(_newSessionPendingText(),1500); + return _newSessionInFlight; } - const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel); - const newModelState=(canQualify&&typeof _modelStateForSelect==='function') - ? _modelStateForSelect(modelSel,selectedDefaultModel) - : {model:selectedDefaultModel,model_provider:null}; - const reqBody={ - model:newModelState.model, - model_provider:newModelState.model_provider||null, - workspace:inheritWs, - profile:S.activeProfile||'default', - }; - if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id; - if(options&&options.worktree) reqBody.worktree=true; - if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject; - const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)}); - S.session=data.session;S.messages=data.session.messages||[]; - S.lastUsage={...(data.session.last_usage||{})}; - if(flash)S.session._flash=true; - try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} - _setActiveSessionUrl(S.session.session_id); - _setSessionViewedCount(S.session.session_id, S.session.message_count || 0); - // Sync chat-header dropdown to the session's model so the UI reflects - // the default model the server actually used (#872). - if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){ - _applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null); - if(typeof syncModelChip==='function') syncModelChip(); + _setNewSessionPending(true); + _newSessionInFlight=(async()=>{ + updateQueueBadge(); + S.toolCalls=[]; + clearLiveToolCards(); + // One-shot profile-switch workspace: applied to the first new session after a profile + // switch, then cleared. Use a dedicated flag so S._profileDefaultWorkspace (the + // persistent boot/settings default) is not consumed and remains available for the + // blank-page display on all subsequent returns to the empty state (#823). + const switchWs=S._profileSwitchWorkspace; + S._profileSwitchWorkspace=null; + const inheritWs=switchWs||(S.session?S.session.workspace:null)||(S._profileDefaultWorkspace||null); + // Use the saved default model for new sessions (#872). The user's saved + // default_model (from Settings) takes priority over the chat-header dropdown + // value, which reflects the *previous* session's model. Fall back to the + // dropdown value only when no default_model is configured. + const modelSel=$('modelSelect'); + const selectedDefaultModel=window._defaultModel||(modelSel&&modelSel.value)||''; + let defaultApplied=false; + if(window._defaultModel&&modelSel&&typeof _applyModelToDropdown==='function'){ + defaultApplied=!!_applyModelToDropdown(window._defaultModel,modelSel,window._activeProvider||null); + } + const canQualify=!window._defaultModel||defaultApplied||(modelSel&&modelSel.value===selectedDefaultModel); + const newModelState=(canQualify&&typeof _modelStateForSelect==='function') + ? _modelStateForSelect(modelSel,selectedDefaultModel) + : {model:selectedDefaultModel,model_provider:null}; + const reqBody={ + model:newModelState.model, + model_provider:newModelState.model_provider||null, + workspace:inheritWs, + profile:S.activeProfile||'default', + }; + if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id; + if(options&&options.worktree) reqBody.worktree=true; + if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject; + const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)}); + S.session=data.session;S.messages=data.session.messages||[]; + S.lastUsage={...(data.session.last_usage||{})}; + if(flash)S.session._flash=true; + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} + _setActiveSessionUrl(S.session.session_id); + _setSessionViewedCount(S.session.session_id, S.session.message_count || 0); + // Sync chat-header dropdown to the session's model so the UI reflects + // the default model the server actually used (#872). + if(S.session.model && S.session.model!==$('modelSelect').value && typeof _applyModelToDropdown==='function'){ + _applyModelToDropdown(S.session.model,$('modelSelect'),S.session.model_provider||null); + if(typeof syncModelChip==='function') syncModelChip(); + } + // Reset per-session visual state: a fresh chat is idle even if another + // conversation is still streaming in the background. + S.busy=false; + S.activeStreamId=null; + updateSendBtn(); + setStatus(''); + setComposerStatus(''); + if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null); + if(typeof _syncCtxIndicator==='function'){ + _syncCtxIndicator({ + input_tokens:data.session.input_tokens||0, + output_tokens:data.session.output_tokens||0, + estimated_cost:data.session.estimated_cost||0, + context_length:data.session.context_length||0, + last_prompt_tokens:data.session.last_prompt_tokens||0, + threshold_tokens:data.session.threshold_tokens||0, + }); + } + updateQueueBadge(S.session.session_id); + syncTopbar();renderMessages();loadDir('.'); + // don't call renderSessionList here - callers do it when needed + })(); + try{ + return await _newSessionInFlight; + }finally{ + _newSessionInFlight=null; + _setNewSessionPending(false); } - // Reset per-session visual state: a fresh chat is idle even if another - // conversation is still streaming in the background. - S.busy=false; - S.activeStreamId=null; - updateSendBtn(); - setStatus(''); - setComposerStatus(''); - if(typeof _setLiveAssistantTps==='function') _setLiveAssistantTps(null); - if(typeof _syncCtxIndicator==='function'){ - _syncCtxIndicator({ - input_tokens:data.session.input_tokens||0, - output_tokens:data.session.output_tokens||0, - estimated_cost:data.session.estimated_cost||0, - context_length:data.session.context_length||0, - last_prompt_tokens:data.session.last_prompt_tokens||0, - threshold_tokens:data.session.threshold_tokens||0, - }); - } - updateQueueBadge(S.session.session_id); - syncTopbar();renderMessages();loadDir('.'); - // don't call renderSessionList here - callers do it when needed } async function loadSession(sid){ diff --git a/static/style.css b/static/style.css index 7a594b90..018a9429 100644 --- a/static/style.css +++ b/static/style.css @@ -2545,6 +2545,8 @@ main.main.showing-logs > #mainLogs{display:flex;} .panel-head-actions{display:flex;align-items:center;gap:4px;text-transform:none;letter-spacing:normal;} .panel-head-btn{width:24px;height:24px;padding:0;display:inline-flex;align-items:center;justify-content:center;border:none;background:transparent;border-radius:6px;color:var(--muted);cursor:pointer;transition:background .15s,color .15s;flex-shrink:0;} .panel-head-btn:hover{background:var(--accent-bg);color:var(--accent-text);} +.panel-head-btn:disabled,.panel-head-btn[aria-busy="true"]{opacity:.5;cursor:wait;} +.panel-head-btn:disabled:hover,.panel-head-btn[aria-busy="true"]:hover{background:transparent;color:var(--muted);} .panel-head-btn svg{width:14px;height:14px;display:block;} .panel-head-sub{padding:6px 12px 8px;font-size:11px;color:var(--muted);flex-shrink:0;} .side-menu{display:flex;flex-direction:column;gap:2px;padding:8px;overflow-y:auto;} diff --git a/tests/test_issue2518_new_session_inflight.py b/tests/test_issue2518_new_session_inflight.py new file mode 100644 index 00000000..ff71d95d --- /dev/null +++ b/tests/test_issue2518_new_session_inflight.py @@ -0,0 +1,37 @@ +"""Regression coverage for #2518 new-conversation cold-start dedupe.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _source(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_new_session_reuses_inflight_request_before_posting_again(): + src = _source("static/sessions.js") + assert "let _newSessionInFlight=null" in src + assert "if(_newSessionInFlight){" in src + assert "return _newSessionInFlight;" in src, ( + "newSession() must return the existing create promise so rapid clicks do " + "not enqueue multiple /api/session/new requests" + ) + assert "_newSessionInFlight=(async()=>" in src + assert "_newSessionInFlight=null;" in src + + +def test_new_session_sets_visible_pending_state_for_cold_catalog_wait(): + src = _source("static/sessions.js") + assert "function _setNewSessionPending(pending)" in src + assert "btn.disabled=!!pending" in src + assert "btn.setAttribute('aria-busy',pending?'true':'false')" in src + assert "setComposerStatus(pendingText)" in src + assert "t('new_session_creating')" in src + + +def test_new_session_pending_button_style_and_copy_exist(): + css = _source("static/style.css") + i18n = _source("static/i18n.js") + assert '.panel-head-btn:disabled,.panel-head-btn[aria-busy="true"]' in css + assert "cursor:wait" in css + assert "new_session_creating: 'Creating new conversation…'" in i18n