diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a9328..aab7ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/boot.js b/static/boot.js index aa36e1e..e4c5831 100644 --- a/static/boot.js +++ b/static/boot.js @@ -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); diff --git a/static/messages.js b/static/messages.js index cbff2d7..109f0f5 100644 --- a/static/messages.js +++ b/static/messages.js @@ -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); diff --git a/static/panels.js b/static/panels.js index a18aae1..f26e501 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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; diff --git a/static/ui.js b/static/ui.js index da0c941..d3f7e9c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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; diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 792ff97..b503b1a 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -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()") diff --git a/tests/test_sprint36.py b/tests/test_sprint36.py index 23d280a..67cd7df 100644 --- a/tests/test_sprint36.py +++ b/tests/test_sprint36.py @@ -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)" ) diff --git a/tests/test_streaming_race_fix.py b/tests/test_streaming_race_fix.py new file mode 100644 index 0000000..925eb22 --- /dev/null +++ b/tests/test_streaming_race_fix.py @@ -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" + ) diff --git a/tests/test_workspace_blank_page_fix.py b/tests/test_workspace_blank_page_fix.py new file mode 100644 index 0000000..90daf4f --- /dev/null +++ b/tests/test_workspace_blank_page_fix.py @@ -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" + )