Merge pull request #5669 from nesquena/release/stage-visible-2026-07-06
Release: visible-UI batch — mobile nav (#5605), mermaid mobile (#5560), steer upload scope (#5630), header font-scale (#5633), three-panel floor (#5594)
This commit is contained in:
10 files changed
+736
-36
No files matched your search
@@ -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)
|
||||
|
||||
+18
-4
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
+29
-5
@@ -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;
|
||||
|
||||
+115
-5
@@ -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)?`<span class="msg-tps-inline" title="Tokens per second">${esc(tpsText)}</span>`:'';
|
||||
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span style="font-size:12px">${esc(_bn)}</span>${tps}</div>`;
|
||||
return `<div class="msg-role assistant" ${tsTitle?`title="${esc(tsTitle)}"`:''}><div class="role-icon assistant">${esc(_bn.charAt(0).toUpperCase())}</div><span class="msg-role-name">${esc(_bn)}</span>${tps}</div>`;
|
||||
}
|
||||
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;i<total;i++){
|
||||
const f=pendingFiles[i];
|
||||
@@ -17998,9 +18108,9 @@ async function uploadPendingFiles(options={}){
|
||||
names.push({name: data.filename, path: data.path, mime: data.mime, size: data.size, is_image: !!data.is_image});
|
||||
}
|
||||
}catch(e){failures++;setStatus(`\u274c ${t('upload_failed')}${f.name} \u2014 ${e.message}`);}
|
||||
bar.style.width=`${Math.round((i+1)/total*100)}%`;
|
||||
_uploadPendingFilesUpdateProgress(sessionId,Math.round((i+1)/total*100));
|
||||
}
|
||||
barWrap.classList.remove('active');bar.style.width='0%';
|
||||
_uploadPendingFilesUpdateProgress(sessionId,null);
|
||||
if(clearPending&&_uploadPendingFilesCurrentSession(sessionId)){S.pendingFiles=[];renderTray();}
|
||||
else if(typeof renderTray==='function'&&_uploadPendingFilesCurrentSession(sessionId))renderTray();
|
||||
if(failures===total&&total>0)throw new Error(t('all_uploads_failed',total));
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = '<svg></svg>';
|
||||
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"]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
+203
-8
@@ -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'")
|
||||
|
||||
Reference in new issue
Block a user