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
+6
View File
@@ -1,5 +1,11 @@
# Hermes Web UI -- Changelog
## [v0.50.138] — 2026-04-22
### Fixed
- **Streaming: response no longer renders twice or leaves thinking block below the answer** — two race conditions in `attachLiveStream` fixed. (A) A trailing `token`/`reasoning` event could queue a `requestAnimationFrame` that fired after `done` had already called `renderMessages()`, inserting a duplicate live-turn wrapper below the settled response. Fixed via `_streamFinalized` flag + `cancelAnimationFrame` in all terminal handlers (`done`, `apperror`, `cancel`, `_handleStreamError`). (B) A proposed accumulator-reset on SSE reconnect was reverted — the server uses a one-shot queue and does not replay events; the reset would have wiped pre-drop response content. Bug A's fix alone resolves all three reported symptoms (double render, thinking card below answer, stuck cursor). (#821, closes #631)
- **Blank new-chat page now shows default workspace and allows workspace actions** — `syncWorkspaceDisplays()` uses `S._profileDefaultWorkspace` as fallback when no session is active; the workspace chip is now enabled on the blank page; `promptNewFile`, `promptNewFolder`, `switchToWorkspace`, and `promptWorkspacePath` all auto-create a session bound to the default workspace when called on the blank page, rather than silently returning. Boot.js hydrates `S._profileDefaultWorkspace` from `/api/settings.default_workspace` before any session is created. (#821, closes #804)
## [v0.50.135] — 2026-04-22
### Fixed
+3
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);
+44 -2
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);
+30 -7
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;
+19
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;
+1 -1
View File
@@ -433,7 +433,7 @@ def test_done_handler_sets_busy_false_before_renderMessages(cleanup_test_session
if done_idx < 0:
done_idx = src.find("es.addEventListener('done'")
assert done_idx >= 0
done_block = src[done_idx:done_idx+2500]
done_block = src[done_idx:done_idx+2900]
# S.busy=false must appear before renderMessages() within the done handler
busy_pos = done_block.find("S.busy=false;")
render_pos = done_block.find("renderMessages()")
+1 -1
View File
@@ -162,7 +162,7 @@ def test_sse_cancel_handler_calls_set_busy():
if idx == -1:
idx = src.find('addEventListener("cancel"')
assert idx != -1
block = src[idx:idx + 1000]
block = src[idx:idx + 1200]
assert "setBusy(false)" in block, (
"SSE cancel handler no longer calls setBusy(false)"
)
+175
View File
@@ -0,0 +1,175 @@
"""Tests for #631 — streaming race conditions in messages.js
Bug A: A trailing 'token'/'reasoning' event queued a requestAnimationFrame that
fired after 'done' had already called renderMessages(), causing the thinking card
to reappear below the final answer or the response to render twice.
Bug B: On SSE reconnect, the closure variables (assistantText, reasoningText)
were not reset. Server replays token events into the new EventSource, causing
text to accumulate again from the stale values response doubled, stuck cursor.
Fixes:
- _streamFinalized flag + _pendingRafHandle stored for cancellation
- done/apperror/cancel: set _streamFinalized, cancel pending rAF, call finalizeThinkingCard
- _scheduleRender: guard on _streamFinalized
- _wireSSE: reset accumulators when (re)opening source, unless stream already finalized
- error handler: bail if _streamFinalized (same as _terminalStateReached)
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text(encoding='utf-8')
class TestStreamFinalized:
"""_streamFinalized flag and rAF cancellation."""
def test_stream_finalized_declared(self):
src = read('static/messages.js')
assert '_streamFinalized' in src, (
"_streamFinalized must be declared in attachLiveStream"
)
def test_pending_raf_handle_declared(self):
src = read('static/messages.js')
assert '_pendingRafHandle' in src, (
"_pendingRafHandle must be declared to enable rAF cancellation"
)
def test_schedule_render_guards_on_stream_finalized(self):
src = read('static/messages.js')
m = re.search(r'function _scheduleRender\(\)\{.*?\n \}', src, re.DOTALL)
assert m, "_scheduleRender not found"
fn = m.group(0)
assert '_streamFinalized' in fn, (
"_scheduleRender must return early when _streamFinalized is true"
)
def test_raf_handle_stored_in_schedule_render(self):
src = read('static/messages.js')
assert '_pendingRafHandle=requestAnimationFrame' in src or \
'_pendingRafHandle = requestAnimationFrame' in src, (
"rAF handle must be stored in _pendingRafHandle for cancellation"
)
def test_done_sets_stream_finalized(self):
src = read('static/messages.js')
m = re.search(r"source\.addEventListener\('done'.*?\}\);", src, re.DOTALL)
assert m, "'done' handler not found"
fn = m.group(0)
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
"'done' handler must set _streamFinalized=true"
)
assert 'cancelAnimationFrame' in fn, (
"'done' handler must cancel any pending rAF"
)
assert 'finalizeThinkingCard' in fn, (
"'done' handler must call finalizeThinkingCard() to close thinking card"
)
def test_apperror_sets_stream_finalized(self):
src = read('static/messages.js')
m = re.search(r"source\.addEventListener\('apperror'.*?\}\);", src, re.DOTALL)
assert m, "'apperror' handler not found"
fn = m.group(0)
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
"'apperror' handler must set _streamFinalized=true"
)
assert 'cancelAnimationFrame' in fn
def test_cancel_sets_stream_finalized(self):
src = read('static/messages.js')
m = re.search(r"source\.addEventListener\('cancel'.*?\}\);", src, re.DOTALL)
assert m, "'cancel' handler not found"
fn = m.group(0)
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
"'cancel' handler must set _streamFinalized=true"
)
assert 'cancelAnimationFrame' in fn
class TestReconnectAccumulatorPreservation:
"""Bug B regression guard: the accumulators must NOT be reset on reconnect.
The original PR description claimed the server "replays buffered token
events" on SSE reconnect, and proposed resetting `assistantText` /
`reasoningText` inside `_wireSSE` to absorb that replay. 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. When a client reconnects with the same `stream_id`, it
picks up from the queue's current position; already-delivered tokens
are NOT re-sent. Resetting the accumulators on reconnect would wipe
the already-displayed content and restart the response from the first
post-reconnect token a data-loss regression.
The "doubled response" / "stuck cursor" symptom that originally
motivated the reset is fully explained by Bug A (trailing rAF after
`done` inserting a duplicate live-turn wrapper). The Bug A fix
(_streamFinalized guard + cancelAnimationFrame in terminal handlers)
resolves both symptoms without needing a reset.
"""
def test_wire_sse_does_not_reset_accumulators(self):
"""Regression guard: _wireSSE must not contain a literal
accumulator-reset statement. Preserves pre-reconnect content so
the user sees the full response across a drop+reconnect."""
src = read('static/messages.js')
m = re.search(r'function _wireSSE\(source\)\{.*?\n \}', src, re.DOTALL)
assert m, "_wireSSE not found"
fn = m.group(0)
assert "assistantText=''" not in fn and 'assistantText = ""' not in fn, (
"_wireSSE must NOT reset assistantText — the server does not replay "
"events on reconnect, so the reset would wipe valid pre-drop content"
)
assert "reasoningText=''" not in fn and 'reasoningText = ""' not in fn, (
"_wireSSE must NOT reset reasoningText on reconnect"
)
def test_closure_initialises_accumulators_empty(self):
"""Initial-connect safety: accumulators are initialised to empty at
the closure scope in attachLiveStream, not inside _wireSSE. That
covers the first call; reconnects must preserve whatever was
accumulated before the drop."""
src = read('static/messages.js')
m = re.search(
r'function attachLiveStream\(.*?function _closeSource',
src,
re.DOTALL,
)
assert m, "attachLiveStream prelude not found"
prelude = m.group(0)
assert "let assistantText=''" in prelude or 'let assistantText = ""' in prelude, (
"assistantText must be initialised to '' at closure scope — "
"this is the only legitimate reset; _wireSSE must not re-reset"
)
def test_error_handler_guards_on_stream_finalized(self):
"""`error` must still bail out when `_streamFinalized` is true —
otherwise a trailing network 'error' event after `done` would
attempt a reconnect against a stream that already completed."""
src = read('static/messages.js')
m = re.search(r"source\.addEventListener\('error'.*?\}\);", src, re.DOTALL)
assert m, "'error' handler not found"
fn = m.group(0)
assert '_streamFinalized' in fn, (
"'error' reconnect handler must bail if _streamFinalized is true"
)
def test_handle_stream_error_sets_stream_finalized(self):
"""Opus review Q1: _handleStreamError is called after the reconnect fails.
It calls renderMessages() which settles the DOM. Any pending rAF must be
cancelled before that renderMessages call same as done/apperror/cancel."""
src = read('static/messages.js')
m = re.search(r'function _handleStreamError\(\)\{.*?\n \}', src, re.DOTALL)
assert m, "_handleStreamError not found"
fn = m.group(0)
assert '_streamFinalized=true' in fn or '_streamFinalized = true' in fn, (
"_handleStreamError must set _streamFinalized=true (Opus Q1 fix)"
)
assert 'cancelAnimationFrame' in fn, (
"_handleStreamError must cancel any pending rAF before renderMessages() runs"
)
+152
View File
@@ -0,0 +1,152 @@
"""Tests for #804 — blank new-chat page loses default workspace binding
Fixes:
- syncWorkspaceDisplays() uses S._profileDefaultWorkspace as fallback when no session
- composerChip.disabled uses hasWorkspace (not hasSession) so chip is enabled on blank page
- boot.js reads default_workspace from /api/settings and sets S._profileDefaultWorkspace
- promptNewFile/promptNewFolder auto-create a session bound to default workspace
"""
import pathlib
import re
REPO = pathlib.Path(__file__).parent.parent
def read(rel):
return (REPO / rel).read_text(encoding='utf-8')
class TestSyncWorkspaceDisplaysFallback:
"""syncWorkspaceDisplays must show default workspace when no session."""
def test_uses_profile_default_workspace_as_fallback(self):
src = read('static/panels.js')
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
assert m, "syncWorkspaceDisplays not found"
fn = m.group(0)
assert '_profileDefaultWorkspace' in fn, (
"syncWorkspaceDisplays must read S._profileDefaultWorkspace as fallback "
"when no active session is present"
)
def test_has_workspace_not_has_session_for_chip_disable(self):
src = read('static/panels.js')
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
# composerChip.disabled must use hasWorkspace, not hasSession
assert 'composerChip.disabled=!hasWorkspace' in fn or \
'composerChip.disabled = !hasWorkspace' in fn, (
"composerChip.disabled must use !hasWorkspace (not !hasSession) so the chip "
"is enabled on the blank new-chat page when a default workspace is configured"
)
assert 'composerChip.disabled=!hasSession' not in fn, (
"composerChip.disabled must not use !hasSession — this was the regression"
)
class TestBootJsProfileDefaultWorkspace:
"""boot.js must read default_workspace from /api/settings into S._profileDefaultWorkspace."""
def test_boot_reads_default_workspace_from_settings(self):
src = read('static/boot.js')
assert '_profileDefaultWorkspace' in src, (
"boot.js must set S._profileDefaultWorkspace from the /api/settings "
"default_workspace field so it is available before any session is created"
)
def test_boot_sets_profile_default_workspace_in_settings_block(self):
"""The settings block (lines ~758-800 in boot.js) must set
S._profileDefaultWorkspace from the /api/settings response."""
src = read('static/boot.js')
# Find the settings fetch and the _profileDefaultWorkspace assignment
# and confirm both are in the same settings-read block (within ~50 lines)
ws_idx = src.find('_profileDefaultWorkspace')
settings_idx = src.find("await api('/api/settings')")
assert ws_idx != -1, "_profileDefaultWorkspace not found in boot.js"
assert settings_idx != -1, "await api('/api/settings') not found in boot.js"
# Both must be within 300 chars of each other (same block)
assert abs(ws_idx - settings_idx) < 1000, (
"S._profileDefaultWorkspace must be set in the same settings-fetch block"
)
class TestPromptNewFileNoSession:
"""promptNewFile/promptNewFolder must auto-create a session on blank page."""
def test_prompt_new_file_auto_creates_session(self):
src = read('static/ui.js')
m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL)
assert m, "promptNewFile not found"
fn = m.group(0)
# Must have auto-create path (not just early return when no session)
assert '_profileDefaultWorkspace' in fn, (
"promptNewFile must read S._profileDefaultWorkspace to auto-create "
"a session when called on the blank new-chat page"
)
assert 'session/new' in fn, (
"promptNewFile must call /api/session/new to create a session "
"bound to the default workspace when S.session is null"
)
def test_prompt_new_folder_auto_creates_session(self):
src = read('static/ui.js')
m = re.search(r'async function promptNewFolder\(\)\{.*?\n\}', src, re.DOTALL)
assert m, "promptNewFolder not found"
fn = m.group(0)
assert '_profileDefaultWorkspace' in fn, (
"promptNewFolder must read S._profileDefaultWorkspace for auto-create path"
)
assert 'session/new' in fn, (
"promptNewFolder must call /api/session/new to create session on blank page"
)
def test_prompt_new_file_still_returns_early_without_default(self):
"""If no default workspace, the function should return early (not crash)."""
src = read('static/ui.js')
m = re.search(r'async function promptNewFile\(\)\{.*?\n\}', src, re.DOTALL)
assert m
fn = m.group(0)
# Must have a guard for empty workspace
assert "if(!ws) return" in fn or "if(!ws)return" in fn, (
"promptNewFile must return early if no default workspace is configured"
)
class TestWorkspaceSwitcherBlankPage:
"""Opus review Q6: workspace switcher dropdown must not silently fail on blank page."""
def test_switch_to_workspace_auto_creates_session(self):
src = read('static/panels.js')
m = re.search(r'async function switchToWorkspace\(.*?\n\}', src, re.DOTALL)
assert m, "switchToWorkspace not found"
fn = m.group(0)
assert '_profileDefaultWorkspace' in fn or 'session/new' in fn, (
"switchToWorkspace must auto-create session on blank page (Opus Q6 fix)"
)
assert 'session/new' in fn, (
"switchToWorkspace must call /api/session/new when S.session is null"
)
def test_prompt_workspace_path_auto_creates_session(self):
src = read('static/panels.js')
m = re.search(r'async function promptWorkspacePath\(\)\{.*?\n\}', src, re.DOTALL)
assert m, "promptWorkspacePath not found"
fn = m.group(0)
assert 'session/new' in fn, (
"promptWorkspacePath must call /api/session/new when S.session is null"
)
def test_sync_workspace_displays_dropdown_close_uses_has_workspace(self):
src = read('static/panels.js')
m = re.search(r'function syncWorkspaceDisplays\(\)\{.*?\n\}', src, re.DOTALL)
assert m, "syncWorkspaceDisplays not found"
fn = m.group(0)
# Line 555: dropdown force-close must use hasWorkspace, not hasSession
assert '!hasWorkspace && composerDropdown' in fn or '!hasWorkspace&&composerDropdown' in fn, (
"syncWorkspaceDisplays must use !hasWorkspace (not !hasSession) to decide "
"whether to force-close the dropdown (Opus Q6 fix)"
)
assert '!hasSession && composerDropdown' not in fn, (
"Regression guard: !hasSession for dropdown close must be removed"
)