diff --git a/CHANGELOG.md b/CHANGELOG.md index fb9c4627..6f115637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ ## [Unreleased] +### Fixed + +- **Mobile drawer shows the dashboard link and extension nav-actions again.** On narrow screens (≤800px) the sidebar drawer was hiding the dashboard link and any extension-added nav-action icons, so you couldn't reach them on mobile. They now mirror into the drawer and stay in sync as they appear/disappear. Thanks @rodboev. (#5605, #5583) +- **Inline Mermaid diagrams no longer collapse to zero height on mobile.** An inline diagram could render invisibly on narrow screens before its SVG laid out; it now has a min-height on mobile (scoped so the lightbox and desktop are unaffected). Thanks @nankingjing. (#5560, #5525) +- **Steer file uploads no longer leak their progress across sessions.** When you steer a live turn with an attachment and switch to another conversation mid-upload, the progress bar and composer status now stay scoped to the session that owns the upload (and the steer indicator shows your original text). Thanks @ruizanthony. (#5630) +- **The chat message header scales with your text-size preference.** The model name, icon, TPS badge, and timestamp in the assistant header were ignoring the font-size setting, so they looked disproportionately small at large/xlarge text; they now scale with the body. Thanks @nankingjing. (#5633) +- **The three-panel desktop layout keeps the conversation readable when you resize.** With the sidebar and workspace panel both open, shrinking the window used to crush the center conversation; it now has a readable floor and the side rails yield first (desktop ≥901px; the workspace panel still fully collapses when closed). Thanks @rodboev. (#5594, #5545) + ### Changed - **The busy-time send behavior is now called "Default message mode," and new installs default to Steer.** The Settings → Preferences control formerly labeled "Busy input mode" is renamed to "Default message mode," and a fresh install now defaults to **Steer** (inject a mid-turn correction without interrupting) instead of Queue. Your existing choice is preserved — if you ever saved settings, your current mode (Queue/Interrupt/Steer) is migrated as-is and unchanged; only never-configured installs pick up the new Steer default. The saved preference still survives a reload or a brief server outage (the localStorage mirror from the previous release is intact). Thanks @rodboev. (#5162, #5145) diff --git a/static/commands.js b/static/commands.js index 51f66037..c1d6dc54 100644 --- a/static/commands.js +++ b/static/commands.js @@ -1431,10 +1431,24 @@ function _steerOwnerIsCurrent(ownerSid){ return !!(ownerSid&&typeof S!=='undefined'&&S.session&&S.session.session_id===ownerSid); } +function _steerSetComposerStatusForOwner(ownerSid,text){ + if(_steerOwnerIsCurrent(ownerSid)&&typeof setComposerStatus==='function')setComposerStatus(text); +} + function _steerRestoreText(originalMsg, explicitSteer){ return explicitSteer?`/steer ${originalMsg}`:originalMsg; } +function _steerIndicatorText(originalMsg, filesSnapshot){ + const text=String(originalMsg||'').trim(); + if(text)return text; + const names=(Array.isArray(filesSnapshot)?filesSnapshot:[]) + .map(f=>f&&(f.name||f.filename||f.path||'')) + .map(v=>String(v||'').trim()) + .filter(Boolean); + return names.length?`Attached files: ${names.join(', ')}`:'Attached files'; +} + async function _steerPersistDraftForOwner(ownerSid, originalMsg, explicitSteer, filesSnapshot){ if(!ownerSid||typeof _saveComposerDraftNow!=='function')return; await _saveComposerDraftNow(ownerSid,_steerRestoreText(originalMsg,explicitSteer),filesSnapshot); @@ -1463,14 +1477,14 @@ async function _steerTextWithPendingFiles(msg, ownerSid, filesSnapshot){ if(_steerUploadCache&&_steerUploadCache.sid===ownerSid&&_steerUploadCache.sig===sig&&Array.isArray(_steerUploadCache.paths)&&_steerUploadCache.paths.length){ paths=_steerUploadCache.paths; }else{ - if(typeof setComposerStatus==='function')setComposerStatus(t('uploading')||'Uploading…'); + _steerSetComposerStatusForOwner(ownerSid,t('uploading')||'Uploading…'); let uploaded=[]; try{ // Keep File objects staged until /api/chat/steer confirms acceptance. If // steer falls back, the draft and chips stay available for Queue/Interrupt. uploaded=await uploadPendingFiles({clearPending:false,sessionId:ownerSid,files:pendingFiles}); }finally{ - if(typeof setComposerStatus==='function')setComposerStatus(''); + _steerSetComposerStatusForOwner(ownerSid,''); } paths=_steerUploadedAttachmentPaths(uploaded); if(paths.length) _steerUploadCache={sid:ownerSid,sig,paths}; @@ -1500,7 +1514,7 @@ async function _trySteer(msg, explicitSteer){ }else{ await _steerPersistDraftForOwner(ownerSid,originalMsg,explicitSteer,pendingFilesSnapshot); } - if(typeof setComposerStatus==='function')setComposerStatus(''); + _steerSetComposerStatusForOwner(ownerSid,''); showToast(`${t('upload_failed')}${e&&e.message?e.message:e}`,3500); return false; } @@ -1546,7 +1560,7 @@ async function _trySteer(msg, explicitSteer){ const _remaining=S.pendingFiles.filter(f=>!_delivered.has(f)); if(_remaining.length!==S.pendingFiles.length){S.pendingFiles=_remaining;if(typeof renderTray==='function')renderTray();} } - _showSteerIndicator(steerText); + _showSteerIndicator(_steerIndicatorText(originalMsg,pendingFilesSnapshot)); } showToast(t('cmd_steer_delivered'),2500); return true; diff --git a/static/sessions.js b/static/sessions.js index ce8185e0..3abfe84e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -1322,6 +1322,7 @@ async function loadSession(sid){ // Mark this session as the in-flight load. Subsequent loadSession() calls // will overwrite this; stale awaits use the mismatch to bail out (#1060). _loadingSessionId = sid; + if(currentSid!==sid&&typeof _uploadPendingFilesSyncProgressForSession==='function')_uploadPendingFilesSyncProgressForSession(sid); // Reset scroll state for fresh session navigation — the reader expects to // land at the bottom of the new transcript, not wherever a stale unpin flag // from a prior session or a stray touch event during loading would place them. diff --git a/static/style.css b/static/style.css index 4fbea1c0..c5d0e6e3 100644 --- a/static/style.css +++ b/static/style.css @@ -72,6 +72,23 @@ :root[data-font-size="large"] .msg-body table { font-size: 14px; } :root[data-font-size="xlarge"] .msg-body table { font-size: 16px; } + /* Chat message role header (model name / icon / TPS / timestamp) */ + :root[data-font-size="small"] .msg-role { font-size: 10px; } + :root[data-font-size="large"] .msg-role { font-size: 13px; } + :root[data-font-size="xlarge"] .msg-role { font-size: 15px; } + :root[data-font-size="small"] .role-icon { width: 18px; height: 18px; font-size: 8px; } + :root[data-font-size="large"] .role-icon { width: 22px; height: 22px; font-size: 10px; } + :root[data-font-size="xlarge"] .role-icon { width: 24px; height: 24px; font-size: 11px; } + :root[data-font-size="small"] .msg-role-name { font-size: 11px; } + :root[data-font-size="large"] .msg-role-name { font-size: 14px; } + :root[data-font-size="xlarge"] .msg-role-name { font-size: 16px; } + :root[data-font-size="small"] .msg-tps-inline { font-size: 9.5px; } + :root[data-font-size="large"] .msg-tps-inline { font-size: 12.5px; } + :root[data-font-size="xlarge"] .msg-tps-inline { font-size: 14.5px; } + :root[data-font-size="small"] .msg-role .msg-time { font-size: 9px; } + :root[data-font-size="large"] .msg-role .msg-time { font-size: 12px; } + :root[data-font-size="xlarge"] .msg-role .msg-time { font-size: 14px; } + /* Composer textarea (default is 16px in stylesheet) */ :root[data-font-size="small"] #msg { font-size: 14px; } :root[data-font-size="large"] #msg { font-size: 18px; } @@ -2104,7 +2121,7 @@ (see line ~681). The rail rule is no longer needed. */ .rail .nav-tab.active::before{content:'';position:absolute;left:-6px;top:50%;bottom:auto;transform:translateY(-50%);width:3px;height:16px;background:var(--accent);border-radius:0 2px 2px 0;} .dashboard-link{position:relative;} - .dashboard-link-visible{display:flex!important;} + .dashboard-link-visible,.nav-action-visible{display:flex!important;} .dashboard-external-badge{position:absolute;right:5px;top:5px;width:9px;height:9px;border-radius:2px;border:1px solid var(--accent);background:var(--sidebar);box-shadow:0 0 0 2px var(--sidebar);opacity:.95;} .dashboard-external-badge::after{content:'';position:absolute;right:-2px;top:-2px;width:5px;height:5px;border-top:1.5px solid var(--accent);border-right:1.5px solid var(--accent);} .sidebar-nav .dashboard-external-badge{right:8px;top:7px;width:8px;height:8px;} @@ -2399,6 +2416,9 @@ .mermaid-viewer-viewport{position:relative;overflow:hidden;max-width:100%;border:1px solid var(--border);border-radius:8px;background:var(--code-bg);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;} .mermaid-viewer-viewport.is-panning{cursor:grabbing;} .mermaid-viewer--inline .mermaid-viewer-viewport{width:100%;max-height:70vh;} + @media (max-width: 640px){ + .mermaid-viewer--inline .mermaid-viewer-viewport{min-height:min(200px,70vh);} + } .mermaid-viewer--lightbox .mermaid-viewer-viewport{max-width:90vw;max-height:90vh;border:none;box-shadow:0 8px 48px rgba(0,0,0,.6);} .mermaid-viewer-canvas{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform;} .mermaid-viewer-svg{display:block;width:100%;height:100%;} @@ -2892,10 +2912,14 @@ .pwa-sidebar-edge-guard{display:none;} @media(min-width:901px){ - html[data-workspace-panel="closed"] .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;} - .layout.workspace-panel-collapsed .rightpanel{width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;} + html[data-workspace-panel="closed"] .rightpanel{width:0 !important;min-width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;} + .layout.workspace-panel-collapsed .rightpanel{width:0 !important;min-width:0 !important;opacity:0;transform:translateX(14px);border-left-color:transparent;pointer-events:none;} html[data-workspace-panel="closed"] .workspace-panel-edge-toggle{display:inline-flex;opacity:1;pointer-events:auto;transform:translateY(-50%);} html[data-workspace-panel="open"] .workspace-panel-edge-toggle{display:none;} + /* Keep the center readable before the rails hit their resize floor. */ + .main{flex:1 1 420px;min-width:420px;} + .sidebar{flex-shrink:1;min-width:180px;} + .rightpanel{flex-shrink:1;min-width:180px;} } /* Sidebar collapse breakpoint matches `_isDesktopWidth()` (min-width:641px) so @@ -3100,8 +3124,7 @@ .sidebar-nav .nav-tab svg{width:20px;height:20px;} .sidebar-nav .nav-tab.active{background:var(--accent-bg);} .sidebar-nav .nav-tab.active::before{left:-4px;top:50%;bottom:auto;transform:translateY(-50%);width:3px;height:16px;border-radius:0 2px 2px 0;} - .sidebar-nav .dashboard-link, - .sidebar-nav .dashboard-link.dashboard-link-visible{display:none!important;} + .sidebar-nav .dashboard-link:not(.nav-action-visible),.sidebar-nav [data-nav-action-mirror]:not(.nav-action-visible){display:none!important;} .sidebar .panel-view{height:100%;margin-left:52px;} .sidebar .panel-head{padding-right:52px;} .mobile-sidebar-close{display:inline-flex!important;position:absolute;right:4px;top:calc(4px + var(--app-titlebar-safe-top));width:44px;height:44px;z-index:4;background:var(--surface);border:1px solid var(--border);} @@ -5876,6 +5899,7 @@ main.main > #mainPlugin{display:none;} /* Quieter, always-visible role header (smaller avatar, always-visible timestamp) */ .msg-role { font-size: 11px; font-weight: 500; margin-bottom: 6px; opacity: .8; letter-spacing: 0; } .msg-role:hover { opacity: 1; } +.msg-role-name { font-size: 12px; } .role-icon { width: 20px; height: 20px; font-size: 9px; } .msg-tps-inline { display: inline-flex; diff --git a/static/ui.js b/static/ui.js index 68c2c523..352caf47 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1458,12 +1458,82 @@ function _dashboardBrowserUrl(status){ const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost; return source.protocol+'//'+displayHost+':'+status.port; } +function _stripInlineEventHandlers(node){ + if(!node)return; + const strip=el=>{ + Array.from(el.attributes||[]).forEach(attr=>{ + if(attr.name&&attr.name.toLowerCase().startsWith('on'))el.removeAttribute(attr.name); + }); + if('onclick' in el)el.onclick=null; + Array.from(el.children||[]).forEach(strip); + }; + strip(node); +} +function _syncNavActionMirrors(){ + const rail=document.querySelector('.rail'); + const sidebar=document.querySelector('.sidebar-nav'); + if(!rail||!sidebar)return; + const sources=Array.from(rail.querySelectorAll('.nav-tab:not([data-panel]):not([data-dashboard-link])')).filter(source=>source.id); + const mirrors=Array.from(sidebar.querySelectorAll('[data-nav-action-mirror]')); + const sourceIds=new Set(sources.map(source=>source.id)); + mirrors.forEach(mirror=>{ + if(!sourceIds.has(mirror.getAttribute('data-nav-action-mirror')))mirror.remove(); + }); + sources.forEach(source=>{ + const sourceVisible=(()=>{ + if(source.hidden||source.getAttribute('aria-hidden')==='true')return false; + if(source.classList.contains('nav-tab-hidden'))return false; + if(source.style&&(source.style.display==='none'||source.style.visibility==='hidden'))return false; + if(typeof window!=='undefined'&&typeof window.getComputedStyle==='function'){ + const computed=window.getComputedStyle(source); + if(computed&&(computed.display==='none'||computed.visibility==='hidden'))return false; + } + return true; + })(); + let mirror=mirrors.find(el=>el.getAttribute('data-nav-action-mirror')===source.id); + if(!mirror){ + mirror=source.cloneNode(true); + _stripInlineEventHandlers(mirror); + mirror.id=source.id+'Mobile'; + mirror.classList.remove('rail-btn'); + mirror.classList.add('has-tooltip--bottom'); + mirror.setAttribute('data-nav-action-mirror',source.id); + mirror.addEventListener('click',e=>{ + e.preventDefault(); + if(mirror._navActionSource)mirror._navActionSource.click(); + if(typeof closeMobileSidebar==='function')closeMobileSidebar(); + }); + const anchor=sidebar.querySelector('.dashboard-link,[data-dashboard-link]')||sidebar.querySelector('[data-panel="logs"]'); + sidebar.insertBefore(mirror,anchor||null); + }else{ + mirror.innerHTML=source.innerHTML; + _stripInlineEventHandlers(mirror); + } + mirror._navActionSource=source; + mirror.classList.toggle('nav-action-visible',sourceVisible); + const label=source.getAttribute('data-tooltip')||source.getAttribute('aria-label')||''; + if(label)mirror.setAttribute('data-label',label); + }); +} +function _initNavActionMirrors(){ + _syncNavActionMirrors(); + const rail=document.querySelector('.rail'); + if(rail&&window.MutationObserver)new MutationObserver(_syncNavActionMirrors).observe(rail,{ + childList:true, + subtree:true, + attributes:true, + attributeFilter:['class','style','hidden','aria-hidden','data-tooltip','aria-label'], + }); +} +if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',_initNavActionMirrors,{once:true}); +else _initNavActionMirrors(); function _applyDashboardStatus(status){ const running=!!(status&&status.running); const url=running?_dashboardBrowserUrl(status):''; const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):''; document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{ btn.classList.toggle('dashboard-link-visible',running); + btn.classList.toggle('nav-action-visible',running); btn.style.display=running?'':'none'; btn.dataset.dashboardUrl=url; const tipText=warning||t('tab_dashboard'); @@ -9540,7 +9610,7 @@ function isTpsDisplayEnabled(){ function _assistantRoleHtml(tsTitle='', tpsText=''){ const _bn=assistantDisplayName(); const tps=(isTpsDisplayEnabled()&&tpsText)?`${esc(tpsText)}`:''; - return `
${esc(_bn.charAt(0).toUpperCase())}
${esc(_bn)}${tps}
`; + return `
${esc(_bn.charAt(0).toUpperCase())}
${esc(_bn)}${tps}
`; } function _setAssistantTurnTps(turn, tpsText=''){ if(!turn) return; @@ -17965,9 +18035,50 @@ function addFiles(files){ } renderTray(); } +const _uploadPendingFilesProgressBySession=new Map(); function _uploadPendingFilesCurrentSession(sessionId){ return !!(!sessionId||(S.session&&S.session.session_id===sessionId)); } +function _uploadPendingFilesHideProgressBar(){ + const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); + if(!bar||!barWrap)return; + barWrap.classList.remove('active'); + bar.style.width='0%'; + if(barWrap.dataset)delete barWrap.dataset.uploadSessionId; +} +function _uploadPendingFilesShowProgressBar(owner,percent){ + const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); + if(!bar||!barWrap)return; + if(barWrap.dataset)barWrap.dataset.uploadSessionId=owner; + barWrap.classList.add('active'); + bar.style.width=`${Math.max(0,Math.min(100,Number(percent)||0))}%`; +} +function _uploadPendingFilesSyncProgressForSession(sessionId){ + const owner=String(sessionId||''); + const state=owner?_uploadPendingFilesProgressBySession.get(owner):null; + if(state){_uploadPendingFilesShowProgressBar(owner,state.percent);return;} + _uploadPendingFilesHideProgressBar(); +} +function _uploadPendingFilesUpdateProgress(sessionId,percent){ + const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); + if(!bar||!barWrap)return; + const owner=String(sessionId||''); + const activeForOwner=barWrap.dataset&&barWrap.dataset.uploadSessionId===owner; + if(percent===null){ + if(owner)_uploadPendingFilesProgressBySession.delete(owner); + if(activeForOwner){ + _uploadPendingFilesHideProgressBar(); + } + return; + } + const clamped=Math.max(0,Math.min(100,Number(percent)||0)); + if(owner)_uploadPendingFilesProgressBySession.set(owner,{percent:clamped}); + if(!_uploadPendingFilesCurrentSession(sessionId)){ + if(activeForOwner)_uploadPendingFilesHideProgressBar(); + return; + } + _uploadPendingFilesShowProgressBar(owner,clamped); +} async function uploadPendingFiles(options={}){ const opts=options||{}; const pendingFiles=Array.isArray(opts.files)?opts.files.filter(Boolean):[...(S.pendingFiles||[])]; @@ -17975,8 +18086,7 @@ async function uploadPendingFiles(options={}){ if(!pendingFiles.length||!sessionId)return[]; const clearPending=!(opts&&opts.clearPending===false); const names=[];let failures=0; - const bar=$('uploadBar');const barWrap=$('uploadBarWrap'); - barWrap.classList.add('active');bar.style.width='0%'; + _uploadPendingFilesUpdateProgress(sessionId,0); const total=pendingFiles.length; for(let i=0;i0)throw new Error(t('all_uploads_failed',total)); diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 00000000..1325bf48 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,9 @@ +"""Shared test helpers for static source assertions.""" + + +def source_between(src: str, start_marker: str, end_marker: str) -> str: + start = src.find(start_marker) + assert start >= 0, f"{start_marker} not found" + end = src.find(end_marker, start) + assert end > start, f"{end_marker} not found after {start_marker}" + return src[start:end] diff --git a/tests/test_1062_busy_input_modes.py b/tests/test_1062_busy_input_modes.py index eb45f40b..80204a1c 100644 --- a/tests/test_1062_busy_input_modes.py +++ b/tests/test_1062_busy_input_modes.py @@ -11,6 +11,8 @@ Issue: #720 (configurable busy-input behaviour) """ from pathlib import Path +from tests.helpers import source_between as _source_between + ROOT = Path(__file__).parent.parent CONFIG_PY = (ROOT / "api" / "config.py").read_text(encoding="utf-8") COMMANDS_JS = (ROOT / "static" / "commands.js").read_text(encoding="utf-8") @@ -22,14 +24,6 @@ INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8") I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") -def _source_between(src, start_marker, end_marker): - start = src.find(start_marker) - assert start >= 0, f"{start_marker} not found" - end = src.find(end_marker, start) - assert end > start, f"{end_marker} not found after {start_marker}" - return src[start:end] - - # ── Backend: setting registration + enum validation ───────────────────── class TestBusyInputModeSetting: diff --git a/tests/test_dashboard_link_ui.py b/tests/test_dashboard_link_ui.py index 9c9dcfbc..8fc4c91c 100644 --- a/tests/test_dashboard_link_ui.py +++ b/tests/test_dashboard_link_ui.py @@ -385,7 +385,7 @@ def test_dashboard_settings_controls_live_in_system_panel(): def test_dashboard_frontend_uses_browser_url_without_requiring_probe_port(): - match = re.search(r"function _dashboardBrowserUrl\(status\).*?\n}\nfunction _applyDashboardStatus", UI_JS, re.DOTALL) + match = re.search(r"function _dashboardBrowserUrl\(status\).*?\n}\nfunction _syncNavActionMirrors", UI_JS, re.DOTALL) assert match is not None helper = match.group(0) assert "status.browser_url||status.url" in helper @@ -393,7 +393,7 @@ def test_dashboard_frontend_uses_browser_url_without_requiring_probe_port(): assert helper.index("status.browser_url||status.url") < helper.index("!status.port") -def test_mobile_dashboard_link_is_scoped_hidden_in_narrow_viewport_css(): +def test_mobile_dashboard_link_uses_shared_visible_action_class(): match = re.search( r"@media\(max-width:640px\)\{([\s\S]*?)\n\s*}\s*\n\s*@media \(hover:none\)", STYLE_CSS, @@ -401,11 +401,219 @@ def test_mobile_dashboard_link_is_scoped_hidden_in_narrow_viewport_css(): ) assert match is not None mobile_css = match.group(1) - assert re.search( - r"\.sidebar-nav\s+\.dashboard-link,\s*\.sidebar-nav\s+\.dashboard-link\.dashboard-link-visible\s*\{\s*display\s*:\s*none\s*!important\s*;\s*}", - mobile_css, - re.DOTALL, + assert re.search(r"\.dashboard-link-visible,\s*\.nav-action-visible\{display:flex!important;\}", STYLE_CSS) + assert ".sidebar-nav .dashboard-link:not(.nav-action-visible)" in mobile_css + assert ".sidebar-nav [data-nav-action-mirror]:not(.nav-action-visible){display:none!important;}" in mobile_css + assert ".sidebar-nav .dashboard-link.dashboard-link-visible{display:none!important;}" not in mobile_css + assert ".sidebar-nav .dashboard-link:not(.dashboard-link-visible){display:none!important;}" not in mobile_css + + +@requires_node +def test_extension_rail_actions_are_mirrored_to_mobile_nav(): + driver = textwrap.dedent( + """\ + const fs = require('fs'); + const src = fs.readFileSync(process.argv[2], 'utf8'); + + function extractFn(name) { + const start = src.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`${name}() not found`); + let i = src.indexOf('{', start); + let depth = 0; + for (; i < src.length; i++) { + if (src[i] === '{') depth += 1; + if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(start, i + 1); + } + } + throw new Error(`could not extract ${name}`); + } + + class El { + constructor(tag) { + this.tagName = tag; + this.children = []; + this._attrs = {}; + this.dataset = {}; + this.innerHTML = ''; + this.id = ''; + this.onclick = null; + this.parentNode = null; + this._handlers = {}; + this.classList = { + _set: new Set(), + add: (...classes) => classes.forEach(c => this.classList._set.add(c)), + remove: (...classes) => classes.forEach(c => this.classList._set.delete(c)), + contains: c => this.classList._set.has(c), + toggle: (cls, force) => { + if (force === undefined) { + if (this.classList._set.has(cls)) { + this.classList._set.delete(cls); + return false; + } + this.classList._set.add(cls); + return true; + } + if (force) this.classList._set.add(cls); + else this.classList._set.delete(cls); + return !!force; + }, + }; + } + appendChild(child) { child.parentNode = this; this.children.push(child); return child; } + insertBefore(child, anchor) { + const idx = anchor ? this.children.indexOf(anchor) : -1; + child.parentNode = this; + if (idx >= 0) this.children.splice(idx, 0, child); + else this.children.push(child); + return child; + } + remove() { + if (!this.parentNode) return; + const idx = this.parentNode.children.indexOf(this); + if (idx >= 0) this.parentNode.children.splice(idx, 1); + this.parentNode = null; + } + setAttribute(name, value) { + this._attrs[name] = String(value); + if (name.startsWith('data-')) this.dataset[name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = String(value); + } + removeAttribute(name) { + delete this._attrs[name]; + if (name.startsWith('data-')) delete this.dataset[name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())]; + } + get attributes() { return Object.entries(this._attrs).map(([name, value]) => ({ name, value })); } + getAttribute(name) { return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null; } + hasAttribute(name) { return Object.prototype.hasOwnProperty.call(this._attrs, name); } + addEventListener(name, fn) { this._handlers[name] = fn; } + click() { if (this._handlers.click) this._handlers.click({ preventDefault() {} }); } + cloneNode() { + const clone = new El(this.tagName); + clone.id = this.id; + clone.innerHTML = this.innerHTML; + clone.onclick = this.onclick; + this.classList._set.forEach(c => clone.classList.add(c)); + Object.entries(this._attrs).forEach(([k, v]) => clone.setAttribute(k, v)); + return clone; + } + querySelectorAll(sel) { + if (sel === '.nav-tab:not([data-panel]):not([data-dashboard-link])') { + return this.children.filter(el => el.classList.contains('nav-tab') && !el.hasAttribute('data-panel') && !el.hasAttribute('data-dashboard-link')); + } + if (sel === '[data-nav-action-mirror]') { + return this.children.filter(el => el.hasAttribute('data-nav-action-mirror')); + } + return []; + } + querySelector(sel) { + if (sel.startsWith('[data-nav-action-mirror="')) { + const id = sel.slice(25, -2); + return this.children.find(el => el.getAttribute('data-nav-action-mirror') === id) || null; + } + if (sel === '.dashboard-link,[data-dashboard-link]') return this.children.find(el => el.classList.contains('dashboard-link') || el.hasAttribute('data-dashboard-link')) || null; + if (sel === '[data-panel="logs"]') return this.children.find(el => el.getAttribute('data-panel') === 'logs') || null; + return null; + } + } + + const rail = new El('nav'); + const sidebar = new El('div'); + const source = new El('button'); + const dashboard = new El('button'); + let clicked = 0; + let sidebarClosed = 0; + source.id = 'hwxThemeCreatorRailBtn'; + source.classList.add('rail-btn', 'nav-tab', 'has-tooltip'); + source.setAttribute('data-tooltip', 'Theme Creator'); + source.setAttribute('onclick', 'clicked += 100;'); + source.onclick = () => { clicked += 100; }; + source.innerHTML = ''; + source.click = () => { clicked += 1; }; + dashboard.classList.add('dashboard-link'); + sidebar.appendChild(dashboard); + let observerCallback = null; + let observedRail = null; + let observedOptions = null; + + global.document = { + readyState: 'complete', + querySelector(sel) { + if (sel === '.rail') return rail; + if (sel === '.sidebar-nav') return sidebar; + return null; + }, + }; + global.closeMobileSidebar = () => { sidebarClosed += 1; }; + global.window = { + getComputedStyle(el) { + return { + display: el.hidden || (el.style && el.style.display === 'none') ? 'none' : '', + visibility: (el.style && el.style.visibility) || '', + }; + }, + }; + global.MutationObserver = window.MutationObserver = class { + constructor(callback) { observerCallback = callback; } + observe(target, options) { observedRail = target; observedOptions = options; } + }; + + const helpers = Function(extractFn('_stripInlineEventHandlers') + '\\n' + extractFn('_syncNavActionMirrors') + '\\n' + extractFn('_initNavActionMirrors') + '; return { _initNavActionMirrors };')(); + helpers._initNavActionMirrors(); + rail.appendChild(source); + observerCallback(); + const mirror = sidebar.children.find(el => el.getAttribute('data-nav-action-mirror') === 'hwxThemeCreatorRailBtn'); + source.hidden = true; + observerCallback(); + const mirrorHiddenWhenSourceHidden = !mirror.classList.contains('nav-action-visible'); + source.hidden = false; + observerCallback(); + mirror.click(); + const mirrorBeforeDashboard = sidebar.children[0] === mirror; + source.remove(); + observerCallback(); + console.log(JSON.stringify({ + observerArmed: observedRail === rail, + observerAttributes: !!(observedOptions && observedOptions.attributes), + observerSubtree: !!(observedOptions && observedOptions.subtree), + sourceVisible: source.classList.contains('nav-action-visible'), + mirrorVisible: mirror.classList.contains('nav-action-visible'), + mirrorHiddenWhenSourceHidden, + mirrorRailClass: mirror.classList.contains('rail-btn'), + mirrorLabel: mirror.getAttribute('data-label'), + mirrorOnclickAttribute: mirror.getAttribute('onclick'), + mirrorOnclickProperty: mirror.onclick === null, + mirrorBeforeDashboard, + mirrorRemoved: !sidebar.children.some(el => el.getAttribute('data-nav-action-mirror') === 'hwxThemeCreatorRailBtn'), + clicked, + sidebarClosed, + })); + """ ) + with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as handle: + handle.write(driver) + path = handle.name + try: + result = subprocess.run([NODE, path, str(UI_PATH)], text=True, capture_output=True, timeout=15, check=True) + out = json.loads(result.stdout) + finally: + pathlib.Path(path).unlink(missing_ok=True) + assert out == { + "observerArmed": True, + "observerAttributes": True, + "observerSubtree": True, + "sourceVisible": False, + "mirrorVisible": True, + "mirrorHiddenWhenSourceHidden": True, + "mirrorRailClass": False, + "mirrorLabel": "Theme Creator", + "mirrorOnclickAttribute": None, + "mirrorOnclickProperty": True, + "mirrorBeforeDashboard": True, + "mirrorRemoved": True, + "clicked": 1, + "sidebarClosed": 1, + } def test_desktop_dashboard_link_button_stays_in_desktop_nav(): @@ -431,6 +639,7 @@ def test_dashboard_dropdown_save_resyncs_chip_state(): assert out["statusCalls"] >= 1 assert all( "dashboard-link-visible" not in state["classes"] + and "nav-action-visible" not in state["classes"] and state["display"] == "none" for state in out["buttonStates"] ) @@ -448,6 +657,7 @@ def test_dashboard_chip_save_keeps_buttons_refreshed(): assert out["statusCalls"] >= 1 assert all( "dashboard-link-visible" in state["classes"] + and "nav-action-visible" in state["classes"] and state["display"] != "none" for state in out["buttonStates"] ) @@ -471,6 +681,7 @@ def test_stale_dashboard_load_does_not_overwrite_newer_save(): assert out["statusCalls"] == 1 assert all( "dashboard-link-visible" in state["classes"] + and "nav-action-visible" in state["classes"] and state["display"] != "none" for state in out["buttonStates"] ) diff --git a/tests/test_issue5545_three_panel_layout.py b/tests/test_issue5545_three_panel_layout.py new file mode 100644 index 00000000..cd7fd255 --- /dev/null +++ b/tests/test_issue5545_three_panel_layout.py @@ -0,0 +1,134 @@ +""" +Issue #5545 three-panel desktop layout regression checks. + +These tests stay source-level so they can verify the declared CSS contract +without needing a browser or rendered layout. +""" + +import pathlib +import re + +REPO = pathlib.Path(__file__).parent.parent +CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + + +def _media_blocks(kind): + pattern = re.compile(rf"@media\s*\(\s*{kind}\s*:\s*(\d+)px\s*\)\s*\{{") + blocks = [] + for match in pattern.finditer(CSS): + width_px = int(match.group(1)) + open_brace = match.end() - 1 + depth = 0 + for idx in range(open_brace, len(CSS)): + if CSS[idx] == "{": + depth += 1 + elif CSS[idx] == "}": + depth -= 1 + if depth == 0: + blocks.append((width_px, CSS[open_brace + 1:idx])) + break + return blocks + + +def _normalize_css(css): + return re.sub(r"\s+", "", css) + + +def _media_block(kind, width_px, needle): + normalized_needle = _normalize_css(needle) + for current_width, block in _media_blocks(kind): + if current_width == width_px and normalized_needle in _normalize_css(block): + return block + raise AssertionError(f"Missing @media({kind}:{width_px}px) block containing {needle!r}") + + +def _strip_css_comments(css): + return re.sub(r"/\*.*?\*/", "", css, flags=re.DOTALL) + + +def _rule_body(css, selector): + for match in re.finditer(r"([^{}]+)\{([^{}]*)\}", _strip_css_comments(css)): + selectors = {part.strip() for part in match.group(1).split(",")} + if selector in selectors: + return match.group(2) + raise AssertionError(f"Missing CSS rule for {selector}") + + +def _declarations(rule_body): + declarations = {} + for item in rule_body.split(";"): + if ":" not in item: + continue + prop, value = item.split(":", 1) + declarations[prop.strip()] = re.sub(r"\s+", " ", value.strip()) + return declarations + + +def test_desktop_three_panel_contract_gives_main_readable_floor(): + desktop = _media_block("min-width", 901, ".main{flex:1 1 420px;min-width:420px;}") + main = _declarations(_rule_body(desktop, ".main")) + + assert main.get("flex") == "1 1 420px" + assert main.get("min-width") == "420px" + + +def test_desktop_side_rails_can_shrink_to_resize_minima(): + desktop = _media_block("min-width", 901, ".sidebar{flex-shrink:1;min-width:180px;}") + sidebar = _declarations(_rule_body(desktop, ".sidebar")) + rightpanel = _declarations(_rule_body(desktop, ".rightpanel")) + closed_rightpanel = _declarations( + _rule_body(desktop, 'html[data-workspace-panel="closed"] .rightpanel') + ) + collapsed_rightpanel = _declarations( + _rule_body(desktop, ".layout.workspace-panel-collapsed .rightpanel") + ) + + assert re.search(r"\bSIDEBAR_MIN\s*=\s*180\b", BOOT_JS) + assert re.search(r"\bPANEL_MIN\s*=\s*180\b", BOOT_JS) + assert sidebar.get("flex-shrink") == "1" + assert sidebar.get("min-width") == "180px" + assert rightpanel.get("flex-shrink") == "1" + assert rightpanel.get("min-width") == "180px" + assert closed_rightpanel.get("width") == "0 !important" + assert closed_rightpanel.get("min-width") == "0 !important" + assert collapsed_rightpanel.get("width") == "0 !important" + assert collapsed_rightpanel.get("min-width") == "0 !important" + + +def test_compact_breakpoint_900px_remains_hidden_right_panel_boundary(): + assert "@media(max-width:900px)" in CSS or "@media (max-width: 900px)" in CSS + + compact = _media_block("max-width", 900, ".rightpanel{display:none}") + rightpanel = _declarations(_rule_body(compact, ".rightpanel")) + workspace_toggle = _declarations(_rule_body(compact, ".workspace-toggle-btn")) + mobile_files = _declarations(_rule_body(compact, ".mobile-files-btn")) + + assert rightpanel.get("display") == "none" + assert workspace_toggle.get("display") == "inline-flex!important" + assert mobile_files.get("display") == "inline-flex!important" + + +def test_mobile_slide_over_breakpoint_640px_remains_intact(): + assert "@media(max-width:640px)" in CSS or "@media (max-width: 640px)" in CSS + + mobile = _media_block("max-width", 640, ".rightpanel.mobile-open") + rightpanel = _declarations(_rule_body(mobile, ".rightpanel")) + rightpanel_open = _declarations(_rule_body(mobile, ".rightpanel.mobile-open")) + + assert rightpanel.get("display") == "flex!important" + assert rightpanel.get("position") == "fixed" + assert rightpanel.get("right") == "calc(-1 * var(--mobile-rightpanel-width))!important" + assert rightpanel.get("width") == "var(--mobile-rightpanel-width)!important" + assert rightpanel.get("box-shadow") == "none!important" + assert rightpanel_open.get("right") == "0!important" + + +def test_no_unmatched_desktop_hide_breakpoint_above_900(): + hidden_widths = [] + for width_px, block in _media_blocks("max-width"): + if re.search(r"\.rightpanel\s*\{\s*display\s*:\s*none", block): + hidden_widths.append(width_px) + + assert 900 in hidden_widths + assert all(width_px <= 900 for width_px in hidden_widths) diff --git a/tests/test_real_steer.py b/tests/test_real_steer.py index bfbf73a6..7546e66c 100644 --- a/tests/test_real_steer.py +++ b/tests/test_real_steer.py @@ -23,6 +23,8 @@ from unittest.mock import MagicMock, patch import pytest +from tests.helpers import source_between as _source_between + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) @@ -81,14 +83,6 @@ def _captured_status(handler): return calls[-1][0][0] -def _source_between(src, start_marker, end_marker): - start = src.find(start_marker) - assert start >= 0, f"{start_marker} not found" - end = src.find(end_marker, start) - assert end > start, f"{end_marker} not found after {start_marker}" - return src[start:end] - - # ── Backend: the /api/chat/steer endpoint ───────────────────────────────── class TestHandleChatSteerHappyPath: @@ -327,6 +321,119 @@ class TestFrontendWiring: "post-await tray/DOM mutations must be guarded by the captured owner session" ) + def test_file_steer_upload_status_and_indicator_are_owner_scoped(self): + steer_helpers = _source_between( + self.cmds, + "function _steerOwnerIsCurrent", + "\nasync function cmdTitle", + ) + try_body = _source_between(self.cmds, "async function _trySteer(", "\nasync function cmdTitle") + assert "function _steerSetComposerStatusForOwner" in steer_helpers + assert "_steerSetComposerStatusForOwner(ownerSid,t('uploading')||'Uploading…')" in steer_helpers + assert "_steerSetComposerStatusForOwner(ownerSid,'')" in steer_helpers + assert "function _steerIndicatorText" in steer_helpers + assert "_showSteerIndicator(_steerIndicatorText(originalMsg,pendingFilesSnapshot))" in try_body, ( + "visible steer indicator must use original text or a file-only display label, not attachment tool instructions" + ) + assert "_showSteerIndicator(steerText)" not in try_body + + def test_file_steer_indicator_omits_attachment_tool_note(self): + import json + import shutil + import subprocess + import textwrap + + node = shutil.which("node") + if not node: # pragma: no cover + pytest.skip("node not available") + assert node is not None + + steer_src = _source_between( + self.cmds, + "function _steerUploadedAttachmentPaths", + "\nasync function cmdTitle", + ) + script = textwrap.dedent( + f""" + const assert = require('assert'); + let S = {{session:{{session_id:'A'}}, pendingFiles:[{{name:'a.pdf'}}]}}; + let apiPayload = null; + let indicatorText = null; + function t(k){{return k;}} + function $(id){{return {{value:'', classList:{{add(){{}}, remove(){{}}}}, style:{{}}}};}} + function setComposerStatus(){{}} + function showToast(){{}} + function renderTray(){{}} + function _showSteerIndicator(text){{indicatorText = text;}} + function _showSteerRecovery(){{}} + function _clearComposerDraft(){{}} + async function uploadPendingFiles(){{return [{{path:'/tmp/a.pdf'}}];}} + async function api(url, options){{ + assert.strictEqual(url, '/api/chat/steer'); + apiPayload = JSON.parse(options.body); + return {{accepted:true}}; + }} + eval({json.dumps(steer_src)}); + (async()=>{{ + const delivered = await _trySteer('hint', false); + assert.strictEqual(delivered, true); + assert.strictEqual(indicatorText, 'hint'); + assert.ok(apiPayload.text.includes('[Attached files for this steer: /tmp/a.pdf]')); + assert.ok(!indicatorText.includes('Attached files')); + assert.ok(!indicatorText.includes('file tools/read_file')); + }})().catch(err=>{{console.error(err); process.exit(1);}}); + """ + ) + subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) + + def test_attachment_only_steer_indicator_uses_file_label(self): + import json + import shutil + import subprocess + import textwrap + + node = shutil.which("node") + if not node: # pragma: no cover + pytest.skip("node not available") + assert node is not None + + steer_src = _source_between( + self.cmds, + "function _steerUploadedAttachmentPaths", + "\nasync function cmdTitle", + ) + script = textwrap.dedent( + f""" + const assert = require('assert'); + let S = {{session:{{session_id:'A'}}, pendingFiles:[{{name:'a.pdf'}}]}}; + let apiPayload = null; + let indicatorText = null; + function t(k){{return k;}} + function $(id){{return {{value:'', classList:{{add(){{}}, remove(){{}}}}, style:{{}}}};}} + function setComposerStatus(){{}} + function showToast(){{}} + function renderTray(){{}} + function _showSteerIndicator(text){{indicatorText = text;}} + function _showSteerRecovery(){{}} + function _clearComposerDraft(){{}} + async function uploadPendingFiles(){{return [{{path:'/tmp/a.pdf'}}];}} + async function api(url, options){{ + assert.strictEqual(url, '/api/chat/steer'); + apiPayload = JSON.parse(options.body); + return {{accepted:true}}; + }} + eval({json.dumps(steer_src)}); + (async()=>{{ + const delivered = await _trySteer('', false); + assert.strictEqual(delivered, true); + assert.strictEqual(indicatorText, 'Attached files: a.pdf'); + assert.ok(apiPayload.text.includes('[Attached files for this steer: /tmp/a.pdf]')); + assert.ok(!indicatorText.includes('file tools/read_file')); + }})().catch(err=>{{console.error(err); process.exit(1);}}); + """ + ) + subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) + def test_file_steer_targets_captured_session_when_user_switches_mid_upload(self): import json import shutil @@ -411,6 +518,94 @@ class TestFrontendWiring: assert "if(clearPending&&_uploadPendingFilesCurrentSession(sessionId)){S.pendingFiles=[];renderTray();}" in ui assert "else if(typeof renderTray==='function'&&_uploadPendingFilesCurrentSession(sessionId))renderTray();" in ui + def test_upload_pending_files_progress_bar_is_session_scoped(self): + ui = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8") + progress_helper = _source_between( + ui, + "const _uploadPendingFilesProgressBySession", + "\nasync function uploadPendingFiles", + ) + upload_body = ui[ui.index("async function uploadPendingFiles") :] + sessions = (Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8") + load_body = _source_between(sessions, "async function loadSession", "\nfunction _isMessagingSession") + assert "_uploadPendingFilesSyncProgressForSession(sid)" in load_body + assert "_uploadPendingFilesProgressBySession.set(owner,{percent:clamped})" in progress_helper + assert "function _uploadPendingFilesSyncProgressForSession" in progress_helper + assert "if(!_uploadPendingFilesCurrentSession(sessionId)){" in progress_helper + assert "barWrap.dataset.uploadSessionId=owner" in progress_helper + assert "activeForOwner" in progress_helper + assert "barWrap.classList.remove('active')" in progress_helper + assert "_uploadPendingFilesUpdateProgress(sessionId,0)" in upload_body + assert "_uploadPendingFilesUpdateProgress(sessionId,Math.round((i+1)/total*100))" in upload_body + assert "_uploadPendingFilesUpdateProgress(sessionId,null)" in upload_body + assert "barWrap.classList.add('active');bar.style.width='0%';" not in upload_body + assert "barWrap.classList.remove('active');bar.style.width='0%';" not in upload_body + + def test_upload_progress_bar_hides_on_switch_and_reappears_on_owner_return(self): + import json + import shutil + import subprocess + import textwrap + + node = shutil.which("node") + if not node: # pragma: no cover + pytest.skip("node not available") + assert node is not None + + ui = (Path(__file__).parent.parent / "static" / "ui.js").read_text(encoding="utf-8") + progress_src = _source_between( + ui, + "const _uploadPendingFilesProgressBySession", + "\nasync function uploadPendingFiles", + ) + script = textwrap.dedent( + f""" + const assert = require('assert'); + let S = {{session:{{session_id:'A'}}}}; + const bar = {{style:{{width:''}}}}; + const barWrap = {{ + dataset: {{}}, + active: false, + classList: {{ + add(cls){{ if(cls === 'active') barWrap.active = true; }}, + remove(cls){{ if(cls === 'active') barWrap.active = false; }}, + }}, + }}; + function $(id){{ + if(id === 'uploadBar') return bar; + if(id === 'uploadBarWrap') return barWrap; + return null; + }} + eval({json.dumps(progress_src)}); + _uploadPendingFilesUpdateProgress('A', 0); + assert.strictEqual(barWrap.active, true); + assert.strictEqual(bar.style.width, '0%'); + assert.strictEqual(barWrap.dataset.uploadSessionId, 'A'); + + S.session = {{session_id:'B'}}; + _uploadPendingFilesSyncProgressForSession('B'); + assert.strictEqual(barWrap.active, false); + assert.strictEqual(bar.style.width, '0%'); + assert.strictEqual(barWrap.dataset.uploadSessionId, undefined); + + _uploadPendingFilesUpdateProgress('A', 50); + assert.strictEqual(barWrap.active, false); + assert.strictEqual(bar.style.width, '0%'); + + S.session = {{session_id:'A'}}; + _uploadPendingFilesSyncProgressForSession('A'); + assert.strictEqual(barWrap.active, true); + assert.strictEqual(bar.style.width, '50%'); + assert.strictEqual(barWrap.dataset.uploadSessionId, 'A'); + + _uploadPendingFilesUpdateProgress('A', null); + assert.strictEqual(barWrap.active, false); + assert.strictEqual(bar.style.width, '0%'); + assert.strictEqual(barWrap.dataset.uploadSessionId, undefined); + """ + ) + subprocess.run([node, "-e", script], check=True, capture_output=True, text=True) + def test_pending_steer_leftover_listener(self): """Frontend must listen for pending_steer_leftover SSE events and queue them.""" idx = self.msgs.find("addEventListener('pending_steer_leftover'")