fix: make error toasts copy-friendly

This commit is contained in:
Michael Lam
2026-05-07 00:02:22 -07:00
committed by nesquena-hermes
parent 49501959b8
commit f704fb52e8
6 changed files with 67 additions and 2 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -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);}

View File

@@ -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=`<span class="toast-message">${esc(s)}</span><button class="toast-copy" type="button" data-toast-copy="1" onclick="copyToastText(this);event.stopPropagation()">Copy</button>`;
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

View File

@@ -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