fix: guard new conversation cold-start clicks
This commit is contained in:
parent
e6be01c4dd
commit
b76d698a79
7 changed files with 149 additions and 65 deletions
|
|
@ -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
|
||||
|
|
|
|||
BIN
docs/pr-media/2518/new-conversation-idle.png
Normal file
BIN
docs/pr-media/2518/new-conversation-idle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/pr-media/2518/new-conversation-pending.png
Normal file
BIN
docs/pr-media/2518/new-conversation-pending.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
37
tests/test_issue2518_new_session_inflight.py
Normal file
37
tests/test_issue2518_new_session_inflight.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue