fix: streaming race conditions (#631) + blank-page workspace binding (#804)

Closes #631. Closes #804.

Bug A (thinking card below answer / double render / stuck cursor): trailing rAF after 'done'
inserted a duplicate live-turn wrapper into already-settled DOM. Fixed via _streamFinalized flag
+ cancelAnimationFrame in all terminal handlers (done/apperror/cancel/_handleStreamError) +
_scheduleRender guard. All three reported symptoms were the same root cause.

Bug B (accumulator reset): original fix reset assistantText/reasoningText inside _wireSSE on reconnect.
Reverted — server uses one-shot queue.Queue(), no replay on reconnect, reset would wipe valid
pre-drop content causing data loss. Bug A fix alone resolves all symptoms.

#804 (blank page workspace): syncWorkspaceDisplays uses S._profileDefaultWorkspace as fallback;
workspace chip enabled when hasWorkspace (not hasSession); promptNewFile/promptNewFolder/
switchToWorkspace/promptWorkspacePath auto-create session on blank page; boot.js hydrates
_profileDefaultWorkspace from /api/settings before any session exists.

Opus max-effort review + Nathan independent review + full browser QA. 1765/1765 tests.
This commit is contained in:
nesquena-hermes
2026-04-21 18:47:40 -07:00
committed by GitHub
parent c3807482be
commit 859602340e
9 changed files with 431 additions and 11 deletions

View File

@@ -767,6 +767,9 @@ function applyBotName(){
window._showThinking=s.show_thinking!==false;
window._sidebarDensity=(s.sidebar_density==='detailed'?'detailed':'compact');
window._botName=s.bot_name||'Hermes';
// Persist default workspace so the blank new-chat page can show it
// and workspace actions (New file/folder) work before the first session (#804).
if(s.default_workspace) S._profileDefaultWorkspace=s.default_workspace;
const appearance=_normalizeAppearance(s.theme,s.skin);
localStorage.setItem('hermes-theme',appearance.theme);
_applyTheme(appearance.theme);

View File

@@ -231,6 +231,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
let _reconnectAttempted=false;
let _terminalStateReached=false;
// Bug A fix (#631): track whether the stream has been finalized so any rAF
// scheduled by a trailing 'token'/'reasoning' event that arrives in the same
// microtask batch as 'done' does not fire after renderMessages() has already
// settled the DOM — which was causing the thinking card to reappear below
// the final answer or the response to render twice.
let _streamFinalized=false;
let _pendingRafHandle=null;
// rAF-throttled rendering: buffer tokens, render at most once per frame
let _renderPending=false;
// Extract display text from assistantText, stripping completed thinking blocks
@@ -306,8 +314,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _scheduleRender(){
if(_renderPending) return;
if(_streamFinalized) return; // Bug A: don't schedule new rAF after stream finalized
_renderPending=true;
requestAnimationFrame(()=>{
_pendingRafHandle=requestAnimationFrame(()=>{
_pendingRafHandle=null;
_renderPending=false;
const parsed=_parseStreamState();
_renderLiveThinking(parsed);
@@ -319,6 +329,21 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _wireSSE(source){
// Note on #631 Bug B: the original PR description stated the server
// "replays buffered token events" on reconnect, and proposed resetting
// the accumulators here so the re-sent tokens wouldn't double the prefix.
// That is NOT how the server actually works — api/routes._handle_sse_stream
// reads a one-shot queue.Queue() that delivers each event to exactly one
// consumer; a reconnect picks up from the current queue position and gets
// only events produced during the outage. Resetting the accumulators here
// would wipe the already-displayed content and restart the response from
// the first post-reconnect token — a real data-loss regression.
//
// The "doubled response" / "stuck cursor" symptom is fully explained by
// Bug A (trailing rAF after `done` inserting a new live-turn wrapper) —
// the fixes below (_streamFinalized guard + cancelAnimationFrame in the
// terminal handlers) address it without needing a reset here.
source.addEventListener('token',e=>{
if(!S.session||S.session.session_id!==activeSid) return;
const d=JSON.parse(e.data);
@@ -446,6 +471,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('done',e=>{
_terminalStateReached=true;
// Bug A fix: cancel any pending rAF and mark stream finalized before
// the DOM is settled by renderMessages, so no trailing token/reasoning rAF
// can reintroduce a stale thinking card or duplicate content.
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
const d=JSON.parse(e.data);
delete INFLIGHT[activeSid];
clearInflight();clearInflightState(activeSid);
@@ -509,6 +540,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('apperror',e=>{
_terminalStateReached=true;
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
// Application-level error sent explicitly by the server (rate limit, crash, etc.)
// This is distinct from the SSE network 'error' event below.
source.close();
@@ -553,7 +587,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('error',async e=>{
source.close();
if(_terminalStateReached){
if(_terminalStateReached || _streamFinalized){
_closeSource();
return;
}
@@ -581,6 +615,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
source.addEventListener('cancel',e=>{
_terminalStateReached=true;
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
source.close();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);
@@ -632,6 +669,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
}
function _handleStreamError(){
// Opus review Q1: mirror done/apperror/cancel finalization so any pending rAF
// cannot fire after renderMessages() has settled the DOM with the error message.
_streamFinalized=true;
if(_pendingRafHandle!==null){cancelAnimationFrame(_pendingRafHandle);_pendingRafHandle=null;_renderPending=false;}
if(typeof finalizeThinkingCard==='function') finalizeThinkingCard();
delete INFLIGHT[activeSid];clearInflight();clearInflightState(activeSid);stopApprovalPolling();stopClarifyPolling();
_closeSource();
if(!_approvalSessionId||_approvalSessionId===activeSid) hideApprovalCard(true);

View File

@@ -537,8 +537,12 @@ function getWorkspaceFriendlyName(path){
function syncWorkspaceDisplays(){
const hasSession=!!(S.session&&S.session.workspace);
const ws=hasSession?S.session.workspace:'';
const label=hasSession?getWorkspaceFriendlyName(ws):t('no_workspace');
// Fall back to the profile default workspace when no session is active yet.
// S._profileDefaultWorkspace is set during boot and profile switches from /api/settings.
const defaultWs=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
const ws=hasSession?S.session.workspace:(defaultWs||'');
const hasWorkspace=!!(ws);
const label=hasWorkspace?getWorkspaceFriendlyName(ws):t('no_workspace');
const sidebarName=$('sidebarWsName');
const sidebarPath=$('sidebarWsPath');
@@ -548,13 +552,13 @@ function syncWorkspaceDisplays(){
const composerChip=$('composerWorkspaceChip');
const composerLabel=$('composerWorkspaceLabel');
const composerDropdown=$('composerWsDropdown');
if(!hasSession && composerDropdown) composerDropdown.classList.remove('open');
if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open');
// Only show workspace label once boot has finished to prevent
// flash of "No workspace" before the saved session finishes loading.
if(composerLabel) composerLabel.textContent=S._bootReady?label:'';
if(composerChip){
composerChip.disabled=!hasSession;
composerChip.title=hasSession?ws:t('no_workspace');
composerChip.disabled=!hasWorkspace;
composerChip.title=hasWorkspace?ws:t('no_workspace');
composerChip.classList.toggle('active',!!(composerDropdown&&composerDropdown.classList.contains('open')));
}
}
@@ -738,7 +742,16 @@ async function removeWorkspace(path){
}
async function promptWorkspacePath(){
if(!S.session)return;
// Opus review Q6: if called from blank page (no session), auto-create one first.
if(!S.session){
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
if(!ws)return;
try{
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();}
}catch(e){showToast(t('workspace_switch_failed')+e.message);return;}
if(!S.session)return;
}
const value=await showPromptDialog({
title:t('workspace_switch_prompt_title'),
message:t('workspace_switch_prompt_message'),
@@ -764,7 +777,17 @@ async function promptWorkspacePath(){
}
async function switchToWorkspace(path,name){
if(!S.session)return;
// Opus review Q6: if called from blank page, auto-create a session bound to
// the requested workspace so the switch doesn't silently no-op.
if(!S.session){
const ws=path||(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
if(!ws){showToast(t('no_workspace'));return;}
try{
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
if(r&&r.session){S.session=r.session;S.messages=[];if(typeof syncTopbar==='function')syncTopbar();if(typeof renderMessages==='function')renderMessages();if(typeof renderSessionList==='function')await renderSessionList();}
}catch(e){if(typeof setStatus==='function')setStatus(t('switch_failed')+e.message);return;}
if(!S.session)return;
}
if(S.busy){
showToast(t('workspace_busy_switch'));
return;

View File

@@ -2417,6 +2417,16 @@ async function deleteWorkspaceFile(relPath, name){
}
async function promptNewFile(){
// If no active session but a default workspace is configured, auto-create
// a session bound to it so workspace actions work on the blank new-chat page.
if(!S.session){
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
if(!ws) return;
try{
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
if(r&&r.session){S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
}catch(e){setStatus(t('create_failed')+e.message);return;}
}
if(!S.session)return;
const name=await showPromptDialog({title:t('new_file_prompt'),placeholder:'filename.txt',confirmLabel:t('create')});
if(!name||!name.trim())return;
@@ -2430,6 +2440,15 @@ async function promptNewFile(){
}
async function promptNewFolder(){
// Same auto-create-session logic as promptNewFile for the blank page.
if(!S.session){
const ws=(typeof S._profileDefaultWorkspace==='string'&&S._profileDefaultWorkspace)||'';
if(!ws) return;
try{
const r=await api('/api/session/new',{method:'POST',body:JSON.stringify({workspace:ws})});
if(r&&r.session){S.session=r.session;S.messages=[];syncTopbar();renderMessages();await renderSessionList();}
}catch(e){setStatus(t('folder_create_failed')+e.message);return;}
}
if(!S.session)return;
const name=await showPromptDialog({title:t('new_folder_prompt'),placeholder:'folder-name',confirmLabel:t('create')});
if(!name||!name.trim())return;