diff --git a/docs/pr-media/1796/error-toast-after.png b/docs/pr-media/1796/error-toast-after.png new file mode 100644 index 00000000..ec1425a7 Binary files /dev/null and b/docs/pr-media/1796/error-toast-after.png differ diff --git a/docs/pr-media/1796/error-toast-before.png b/docs/pr-media/1796/error-toast-before.png new file mode 100644 index 00000000..f9dd0487 Binary files /dev/null and b/docs/pr-media/1796/error-toast-before.png differ diff --git a/docs/pr-media/1796/error-toast-copy.png b/docs/pr-media/1796/error-toast-copy.png new file mode 100644 index 00000000..eeff0f39 Binary files /dev/null and b/docs/pr-media/1796/error-toast-copy.png differ diff --git a/static/style.css b/static/style.css index c3ba3162..772cf0ae 100644 --- a/static/style.css +++ b/static/style.css @@ -465,10 +465,13 @@ .app-dialog-btn.confirm.danger{border-color:var(--error);background:rgba(239,83,80,.12);color:var(--error);} .app-dialog-btn.confirm.danger:hover{background:rgba(239,83,80,.2);border-color:var(--error);} .app-dialog-btn:focus-visible,.app-dialog-close:focus-visible{outline:2px solid var(--accent);outline-offset:2px;} - .toast{position:fixed;top:24px;right:24px;left:auto;bottom:auto;transform:translateY(-6px);background:color-mix(in srgb,var(--accent) 14%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--surface));color:var(--accent-text);font-size:13px;font-weight:500;padding:10px 16px;border-radius:10px;pointer-events:none;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 6px 24px rgba(0,0,0,.12);letter-spacing:.01em;max-width:min(420px,calc(100vw - 48px));} + .toast{pointer-events:auto;position:fixed;top:24px;right:24px;left:auto;bottom:auto;transform:translateY(-6px);display:flex;align-items:center;gap:10px;background:color-mix(in srgb,var(--accent) 14%,var(--surface));border:1px solid color-mix(in srgb,var(--accent) 45%,var(--surface));color:var(--accent-text);font-size:13px;font-weight:500;padding:10px 12px 10px 16px;border-radius:10px;opacity:0;transition:opacity .2s,transform .2s;z-index:100;box-shadow:0 6px 24px rgba(0,0,0,.12);letter-spacing:.01em;max-width:min(520px,calc(100vw - 48px));} .toast.show{opacity:1;transform:translateY(0);} .toast.success{background:color-mix(in srgb,var(--success) 14%,var(--surface));border-color:color-mix(in srgb,var(--success) 45%,var(--surface));color:var(--success);} .toast.error{background:color-mix(in srgb,var(--error) 14%,var(--surface));border-color:color-mix(in srgb,var(--error) 45%,var(--surface));color:var(--error);} + .toast-message{min-width:0;overflow-wrap:anywhere;white-space:pre-wrap;} + .toast-copy{border:1px solid currentColor;background:transparent;color:inherit;border-radius:8px;padding:4px 8px;font:inherit;font-size:12px;font-weight:700;cursor:pointer;opacity:.85;} + .toast-copy:hover,.toast-copy:focus-visible{opacity:1;background:color-mix(in srgb,currentColor 10%,transparent);outline:none;} .toast.warning{background:color-mix(in srgb,var(--warning) 14%,var(--surface));border-color:color-mix(in srgb,var(--warning) 45%,var(--surface));color:var(--warning);} .onboarding-overlay{position:fixed;inset:0;z-index:1050;background:rgba(7,12,19,.78);backdrop-filter:blur(8px);display:none;align-items:center;justify-content:center;padding:24px;} .onboarding-card{width:min(980px,100%);max-height:min(760px,94vh);overflow:auto;border:1px solid var(--accent-bg-strong);border-radius:24px;background:linear-gradient(180deg,rgba(20,30,44,.98),rgba(11,17,27,.98));box-shadow:0 24px 80px rgba(0,0,0,.45);} diff --git a/static/ui.js b/static/ui.js index bd47dda2..51e1a4fe 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2917,7 +2917,31 @@ function updateQueueBadge(sessionId){ } } } -function showToast(msg,ms,type){const el=$('toast');if(!el)return;const s=String(msg==null?'':msg);let t=type;if(!t){const low=s.toLowerCase();if(/fail|error|denied|invalid|unavailable|no active|no workspace match|no model match|no personalities/.test(low))t='error';else if(/warn|queued|takes effect|skipped|fallback/.test(low))t='warning';else if(/saved|created|imported|restored|switched|set to|updated|duplicated|moved to|renamed|deleted|complete|pinned|archived|cleared|stopped/.test(low))t='success';else t='info';}el.textContent=s;el.className='toast show '+t;clearTimeout(el._t);el._t=setTimeout(()=>{el.classList.remove('show');},ms||2800);} +const TOAST_DEFAULT_MS=2800; +const TOAST_ERROR_DEFAULT_MS=20000; +function clearToastDismissTimer(el){if(!el)return;clearTimeout(el._t);el._t=null;} +function setToastDismissTimer(el,duration){if(!el)return;clearToastDismissTimer(el);el._t=setTimeout(()=>{el.classList.remove('show');},duration);} +function copyToastText(btn){ + const el=btn&&btn.closest?btn.closest('#toast'):null; + const text=el?(el.dataset.toastMessage||el.textContent||''):''; + const done=()=>{const old=btn.textContent;btn.textContent='Copied';setTimeout(()=>{btn.textContent=old;},1200);}; + _copyText(text).then(done).catch(()=>{}); +} +function showToast(msg,ms,type){ + const el=$('toast');if(!el)return; + const s=String(msg==null?'':msg);let t=type; + if(!t){const low=s.toLowerCase();if(/fail|error|denied|invalid|unavailable|no active|no workspace match|no model match|no personalities/.test(low))t='error';else if(/warn|queued|takes effect|skipped|fallback/.test(low))t='warning';else if(/saved|created|imported|restored|switched|set to|updated|duplicated|moved to|renamed|deleted|complete|pinned|archived|cleared|stopped/.test(low))t='success';else t='info';} + const duration=(ms==null)?(t==='error'?TOAST_ERROR_DEFAULT_MS:TOAST_DEFAULT_MS):ms; + el.className='toast show '+t; + el.dataset.toastMessage=s; + if(t==='error') el.innerHTML=`${esc(s)}`; + else el.textContent=s; + el.onmouseenter=()=>clearToastDismissTimer(el); + el.onmouseleave=()=>setToastDismissTimer(el,duration); + el.onfocusin=()=>clearToastDismissTimer(el); + el.onfocusout=()=>setToastDismissTimer(el,duration); + setToastDismissTimer(el,duration); +} // ── Shared app dialogs ─────────────────────────────────────────────────────── // showConfirmDialog(opts) and showPromptDialog(opts) replace browser-native dialog calls diff --git a/tests/test_issue1796_error_toasts.py b/tests/test_issue1796_error_toasts.py new file mode 100644 index 00000000..0a0e4997 --- /dev/null +++ b/tests/test_issue1796_error_toasts.py @@ -0,0 +1,38 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text() +STYLE_CSS = (ROOT / "static" / "style.css").read_text() + + +def test_error_toast_default_duration_is_substantially_longer_than_info_toasts(): + assert "const TOAST_DEFAULT_MS=2800" in UI_JS + assert "const TOAST_ERROR_DEFAULT_MS=20000" in UI_JS + assert "const duration=(ms==null)?(t==='error'?TOAST_ERROR_DEFAULT_MS:TOAST_DEFAULT_MS):ms" in UI_JS + assert "ms||2800" not in UI_JS + + +def test_error_toast_keeps_explicit_duration_override(): + show_toast = UI_JS[UI_JS.index("function showToast"):UI_JS.index("// ── Shared app dialogs")] + assert "ms==null" in show_toast + assert "?TOAST_ERROR_DEFAULT_MS" in show_toast + assert ":TOAST_DEFAULT_MS" in show_toast + assert "setToastDismissTimer(el,duration)" in show_toast + + +def test_error_toast_has_copy_button_for_exact_error_text(): + show_toast = UI_JS[UI_JS.index("function showToast"):UI_JS.index("// ── Shared app dialogs")] + assert "toast-copy" in show_toast + assert "data-toast-copy" in show_toast + assert "copyToastText" in show_toast + assert "const text=el?(el.dataset.toastMessage||el.textContent||''):''" in UI_JS + assert "_copyText(text).then(done).catch(()=>{})" in UI_JS + + +def test_toast_dismissal_pauses_on_hover_and_keyboard_focus(): + assert "onmouseenter=()=>clearToastDismissTimer(el)" in UI_JS + assert "onmouseleave=()=>setToastDismissTimer(el,duration)" in UI_JS + assert "onfocusin=()=>clearToastDismissTimer(el)" in UI_JS + assert "onfocusout=()=>setToastDismissTimer(el,duration)" in UI_JS + assert ".toast{pointer-events:auto" in STYLE_CSS + assert ".toast-copy" in STYLE_CSS