diff --git a/src/chrome/src/message-info.js b/src/chrome/src/message-info.js index 23e3d9a0f..e1218307f 100644 --- a/src/chrome/src/message-info.js +++ b/src/chrome/src/message-info.js @@ -1,6 +1,7 @@ function formatSentTime(createdAt, locale) { const value = Number(createdAt); if (!Number.isFinite(value) || value <= 0) return ''; + const date = new Date(value); try { return new Intl.DateTimeFormat(locale || undefined, { year: 'numeric', @@ -9,11 +10,15 @@ function formatSentTime(createdAt, locale) { hour: '2-digit', minute: '2-digit', hour12: false, - timeZone: 'UTC', timeZoneName: 'short', - }).format(new Date(value)); + }).format(date); } catch { - return new Date(value).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + const pad = (part) => String(part).padStart(2, '0'); + const offsetMinutes = -date.getTimezoneOffset(); + const offset = offsetMinutes === 0 + ? 'UTC' + : `UTC${offsetMinutes > 0 ? '+' : '-'}${pad(Math.floor(Math.abs(offsetMinutes) / 60))}:${pad(Math.abs(offsetMinutes) % 60)}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}, ${pad(date.getHours())}:${pad(date.getMinutes())} ${offset}`; } } diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index 05b438595..da07e4e40 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 49daf147b..c7335569f 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index 3db516d21..8726044a5 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index e430c1f8f..27e6fe83c 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -3,7 +3,7 @@ import apocalypseModeCopy from './apocalypse-copy.mjs'; import emergencyCopy from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index f37418efc..51663d1dc 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index 7d8b88381..ef753bbbc 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index a140d3fe3..09be0789d 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index 85222d98f..073c7487d 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 12304390f..3ee4234d3 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index 04de4b1e0..d26498cab 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index ba70fb7e2..ae1a2ff23 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index e9244e6e3..a96e8d990 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index eb6723152..d50f67ae4 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index 6371fef1f..d4c7203ab 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index e902e4bcc..ea3d931e0 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 6cd39b368..ca9dfd57f 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 7e51b11a7..d69258cf5 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index 680e6c97c..c9fc998be 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index 7a848ac4e..2cf1e16d5 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index ed69faa89..93f74bdc5 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index c3da43529..419791581 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index 56e0ffdb2..23e1c3aa8 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index 4275dc0a2..5852a430e 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index ee918ce75..f9e1ab47a 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10546,12 +10546,25 @@ function messageCompletionFromElement(msgEl) { let messageInfoRowId = 0; function ensureMessageInfoElements(msgEl) { - let row = msgEl.querySelector(':scope > .message-info'); + let bar = msgEl.querySelector(':scope > .message-info-bar'); + let row = bar?.querySelector(':scope > .message-info') + || msgEl.querySelector(':scope > .message-info'); + let toggle = msgEl.querySelector(':scope > .message-info-toggle') + || bar?.querySelector(':scope > .message-info-toggle'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'message-info-bar'; + const legacyControl = toggle || row; + if (legacyControl) msgEl.insertBefore(bar, legacyControl); + else msgEl.appendChild(bar); + } if (!row) { row = document.createElement('div'); row.className = 'message-info'; row.setAttribute('role', 'status'); - msgEl.appendChild(row); + } + if (row.parentNode !== bar) { + bar.appendChild(row); } if (!row.id) { let id; @@ -10560,13 +10573,14 @@ function ensureMessageInfoElements(msgEl) { } while (document.getElementById(id)); row.id = id; } - let toggle = msgEl.querySelector(':scope > .message-info-toggle'); if (!toggle) { toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'message-info-toggle'; - toggle.textContent = 'ⓘ'; - msgEl.insertBefore(toggle, row); + } + toggle.textContent = ''; + if (toggle.parentNode !== msgEl) { + msgEl.insertBefore(toggle, msgEl.children[0] || null); } toggle.setAttribute('aria-controls', row.id); toggle.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index 300023997..8079b983b 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -1396,6 +1396,7 @@ body { /* Messages */ .message { + position: relative; display: flex; flex-direction: column; max-width: 95%; @@ -1495,31 +1496,44 @@ body { border-bottom-left-radius: 4px; } -.message-info-toggle { - align-self: flex-start; - width: 22px; - height: 22px; - margin: 3px 3px 0; - padding: 0; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 14px; - line-height: 22px; - opacity: 0.72; +.message-info-bar { + position: absolute; + inset-block-start: 100%; + inset-inline-start: 0; + display: none; + align-items: center; + width: max-content; + max-width: calc(100vw - 24px); + min-width: 0; + margin-top: 3px; } -.message.user .message-info-toggle { - align-self: flex-end; +.message.message-info-open { + margin-bottom: 25px; } -.message-info-toggle:hover, -.message-info-toggle:focus-visible { - background: var(--overlay-bg); - color: var(--text-primary); - opacity: 1; +.message.message-info-open > .message-info-bar { + display: flex; +} + +.message.user .message-info-bar { + inset-inline-start: auto; + inset-inline-end: 0; +} + +.message-info-toggle { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: var(--radius); + appearance: none; + background: transparent; + pointer-events: none; } .message-info-toggle:focus-visible { @@ -1529,11 +1543,15 @@ body { .message-info { display: flex; - flex-wrap: wrap; + flex: 0 1 auto; + flex-wrap: nowrap; align-items: center; gap: 6px; - margin-top: 5px; + min-width: 0; + margin: 0; padding: 0 4px; + overflow: hidden; + white-space: nowrap; color: var(--text-secondary); font-size: 10px; font-variant-numeric: tabular-nums; @@ -1541,7 +1559,6 @@ body { } .message.user .message-info { - align-self: flex-end; justify-content: flex-end; } diff --git a/src/firefox/src/message-info.js b/src/firefox/src/message-info.js index 23e3d9a0f..e1218307f 100644 --- a/src/firefox/src/message-info.js +++ b/src/firefox/src/message-info.js @@ -1,6 +1,7 @@ function formatSentTime(createdAt, locale) { const value = Number(createdAt); if (!Number.isFinite(value) || value <= 0) return ''; + const date = new Date(value); try { return new Intl.DateTimeFormat(locale || undefined, { year: 'numeric', @@ -9,11 +10,15 @@ function formatSentTime(createdAt, locale) { hour: '2-digit', minute: '2-digit', hour12: false, - timeZone: 'UTC', timeZoneName: 'short', - }).format(new Date(value)); + }).format(date); } catch { - return new Date(value).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + const pad = (part) => String(part).padStart(2, '0'); + const offsetMinutes = -date.getTimezoneOffset(); + const offset = offsetMinutes === 0 + ? 'UTC' + : `UTC${offsetMinutes > 0 ? '+' : '-'}${pad(Math.floor(Math.abs(offsetMinutes) / 60))}:${pad(Math.abs(offsetMinutes) % 60)}`; + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}, ${pad(date.getHours())}:${pad(date.getMinutes())} ${offset}`; } } diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js index 184538d84..72bc88d7d 100644 --- a/src/firefox/src/ui/locales/ar.js +++ b/src/firefox/src/ui/locales/ar.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index 87dcb471a..9aae4a04f 100644 --- a/src/firefox/src/ui/locales/bn.js +++ b/src/firefox/src/ui/locales/bn.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index d73af4ef0..1da3fdb2f 100644 --- a/src/firefox/src/ui/locales/de.js +++ b/src/firefox/src/ui/locales/de.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index 22a5fccf7..7e26947c8 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -3,7 +3,7 @@ import apocalypseModeCopy from './apocalypse-copy.mjs'; import emergencyCopy from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index d4b5e0f31..eea70bbc1 100644 --- a/src/firefox/src/ui/locales/es.js +++ b/src/firefox/src/ui/locales/es.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index f16bb657c..b472e264d 100644 --- a/src/firefox/src/ui/locales/fa.js +++ b/src/firefox/src/ui/locales/fa.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index 7677f4779..f3cf5b7a8 100644 --- a/src/firefox/src/ui/locales/fr.js +++ b/src/firefox/src/ui/locales/fr.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index 4870f5624..c4c92204d 100644 --- a/src/firefox/src/ui/locales/he.js +++ b/src/firefox/src/ui/locales/he.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index 7f1aa2acf..c5055004d 100644 --- a/src/firefox/src/ui/locales/hi.js +++ b/src/firefox/src/ui/locales/hi.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index c4fe56e06..6b7771625 100644 --- a/src/firefox/src/ui/locales/id.js +++ b/src/firefox/src/ui/locales/id.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 89569172f..fb2909ea0 100644 --- a/src/firefox/src/ui/locales/ja.js +++ b/src/firefox/src/ui/locales/ja.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index 9b188d2a9..c72a7ac3f 100644 --- a/src/firefox/src/ui/locales/ko.js +++ b/src/firefox/src/ui/locales/ko.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index 5bcb7eddc..09aaea7ce 100644 --- a/src/firefox/src/ui/locales/ms.js +++ b/src/firefox/src/ui/locales/ms.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index ddea087d8..153cbd2ab 100644 --- a/src/firefox/src/ui/locales/nl.js +++ b/src/firefox/src/ui/locales/nl.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index 9017cf7b0..ac7805ac8 100644 --- a/src/firefox/src/ui/locales/pl.js +++ b/src/firefox/src/ui/locales/pl.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index 818eec866..4c8b5ac2a 100644 --- a/src/firefox/src/ui/locales/pt.js +++ b/src/firefox/src/ui/locales/pt.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index 77f4dac96..bfd9d017b 100644 --- a/src/firefox/src/ui/locales/ru.js +++ b/src/firefox/src/ui/locales/ru.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index 782e967a7..c0b219823 100644 --- a/src/firefox/src/ui/locales/th.js +++ b/src/firefox/src/ui/locales/th.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index d20c9be14..63bce21e9 100644 --- a/src/firefox/src/ui/locales/tl.js +++ b/src/firefox/src/ui/locales/tl.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index 681926318..19ac38ab1 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index 6dc589768..16bfe6ee1 100644 --- a/src/firefox/src/ui/locales/uk.js +++ b/src/firefox/src/ui/locales/uk.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index eb2037640..ab1a265cc 100644 --- a/src/firefox/src/ui/locales/vi.js +++ b/src/firefox/src/ui/locales/vi.js @@ -3,7 +3,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index 0f98cfb2c..7032dc6e0 100644 --- a/src/firefox/src/ui/locales/zh.js +++ b/src/firefox/src/ui/locales/zh.js @@ -5,7 +5,7 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; import { getEmergencyBoxCopy } from './emergency-copy.mjs'; export default { - 'sp.message_info.sent': '(sent {time})', + 'sp.message_info.sent': 'sent {time}', 'sp.message_info.speed': '{rate} tok/sec', 'sp.message_info.tokens': '{count} tokens', 'sp.message_info.duration': '{seconds}s', diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 863da17b3..3d7f6b535 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -10167,12 +10167,25 @@ function messageCompletionFromElement(msgEl) { let messageInfoRowId = 0; function ensureMessageInfoElements(msgEl) { - let row = msgEl.querySelector(':scope > .message-info'); + let bar = msgEl.querySelector(':scope > .message-info-bar'); + let row = bar?.querySelector(':scope > .message-info') + || msgEl.querySelector(':scope > .message-info'); + let toggle = msgEl.querySelector(':scope > .message-info-toggle') + || bar?.querySelector(':scope > .message-info-toggle'); + if (!bar) { + bar = document.createElement('div'); + bar.className = 'message-info-bar'; + const legacyControl = toggle || row; + if (legacyControl) msgEl.insertBefore(bar, legacyControl); + else msgEl.appendChild(bar); + } if (!row) { row = document.createElement('div'); row.className = 'message-info'; row.setAttribute('role', 'status'); - msgEl.appendChild(row); + } + if (row.parentNode !== bar) { + bar.appendChild(row); } if (!row.id) { let id; @@ -10181,13 +10194,14 @@ function ensureMessageInfoElements(msgEl) { } while (document.getElementById(id)); row.id = id; } - let toggle = msgEl.querySelector(':scope > .message-info-toggle'); if (!toggle) { toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'message-info-toggle'; - toggle.textContent = 'ⓘ'; - msgEl.insertBefore(toggle, row); + } + toggle.textContent = ''; + if (toggle.parentNode !== msgEl) { + msgEl.insertBefore(toggle, msgEl.children[0] || null); } toggle.setAttribute('aria-controls', row.id); toggle.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index f2b2986e1..f71ddc2de 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -1233,6 +1233,7 @@ body { /* Messages */ .message { + position: relative; display: flex; flex-direction: column; max-width: 95%; @@ -1332,31 +1333,44 @@ body { border-bottom-left-radius: 4px; } -.message-info-toggle { - align-self: flex-start; - width: 22px; - height: 22px; - margin: 3px 3px 0; - padding: 0; - border: 0; - border-radius: 50%; - background: transparent; - color: var(--text-secondary); - cursor: pointer; - font-size: 14px; - line-height: 22px; - opacity: 0.72; +.message-info-bar { + position: absolute; + inset-block-start: 100%; + inset-inline-start: 0; + display: none; + align-items: center; + width: max-content; + max-width: calc(100vw - 24px); + min-width: 0; + margin-top: 3px; } -.message.user .message-info-toggle { - align-self: flex-end; +.message.message-info-open { + margin-bottom: 25px; } -.message-info-toggle:hover, -.message-info-toggle:focus-visible { - background: var(--overlay-bg); - color: var(--text-primary); - opacity: 1; +.message.message-info-open > .message-info-bar { + display: flex; +} + +.message.user .message-info-bar { + inset-inline-start: auto; + inset-inline-end: 0; +} + +.message-info-toggle { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + margin: 0; + padding: 0; + border: 0; + border-radius: var(--radius); + appearance: none; + background: transparent; + pointer-events: none; } .message-info-toggle:focus-visible { @@ -1366,11 +1380,15 @@ body { .message-info { display: flex; - flex-wrap: wrap; + flex: 0 1 auto; + flex-wrap: nowrap; align-items: center; gap: 6px; - margin-top: 5px; + min-width: 0; + margin: 0; padding: 0 4px; + overflow: hidden; + white-space: nowrap; color: var(--text-secondary); font-size: 10px; font-variant-numeric: tabular-nums; @@ -1378,7 +1396,6 @@ body { } .message.user .message-info { - align-self: flex-end; justify-content: flex-end; } diff --git a/test/run.js b/test/run.js index 873de583e..2b7215cde 100644 --- a/test/run.js +++ b/test/run.js @@ -84169,81 +84169,112 @@ test('transcription runtime uses the Chrome offscreen fallback when direct fetch } }); -test('message info keeps normal mode limited to the sent timestamp', async () => { - for (const [label, rel] of [ - ['chrome', 'src/chrome/src/message-info.js'], - ['firefox', 'src/firefox/src/message-info.js'], - ]) { - const { buildMessageInfoPills } = await import(pathToFileURL(path.join(ROOT, rel)).href); - const pills = buildMessageInfoPills({ - createdAt: Date.parse('2024-12-12T12:44:00Z'), - completion: { - outputTokens: 1295, - durationMs: 7560, - finishReason: 'stop', - }, - verbose: false, - locale: 'en-GB', - }); +const MESSAGE_INFO_TEST_DATE = Date.parse('2024-12-12T12:44:00Z'); +const MESSAGE_INFO_TEST_TIME_ZONE = 'America/Los_Angeles'; - assert.deepEqual(pills, [{ - kind: 'sent', - key: 'sp.message_info.sent', - params: { time: '12/12/2024, 12:44 UTC' }, - }], `${label}: normal mode must not expose token or provider details`); +async function withProcessTimeZone(timeZone, callback) { + const previous = process.env.TZ; + process.env.TZ = timeZone; + try { + return await callback(); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; } +} + +function expectedMessageInfoTestTime() { + return new Intl.DateTimeFormat('en-GB', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: MESSAGE_INFO_TEST_TIME_ZONE, + timeZoneName: 'short', + }).format(new Date(MESSAGE_INFO_TEST_DATE)); +} + +test('message info keeps normal mode limited to the system-timezone sent timestamp', async () => { + await withProcessTimeZone(MESSAGE_INFO_TEST_TIME_ZONE, async () => { + for (const [label, rel] of [ + ['chrome', 'src/chrome/src/message-info.js'], + ['firefox', 'src/firefox/src/message-info.js'], + ]) { + const { buildMessageInfoPills } = await import(pathToFileURL(path.join(ROOT, rel)).href); + const pills = buildMessageInfoPills({ + createdAt: MESSAGE_INFO_TEST_DATE, + completion: { + outputTokens: 1295, + durationMs: 7560, + finishReason: 'stop', + }, + verbose: false, + locale: 'en-GB', + }); + + assert.deepEqual(pills, [{ + kind: 'sent', + key: 'sp.message_info.sent', + params: { time: expectedMessageInfoTestTime() }, + }], `${label}: normal mode must use the system timezone without exposing token or provider details`); + } + }); }); test('message info aggregates model calls into verbose completion pills', async () => { - for (const [label, rel] of [ - ['chrome', 'src/chrome/src/message-info.js'], - ['firefox', 'src/firefox/src/message-info.js'], - ]) { - const { aggregateMessageCompletion, buildMessageInfoPills } = await import( - pathToFileURL(path.join(ROOT, rel)).href - ); - const first = aggregateMessageCompletion(null, { - usage: { prompt_tokens: 1000, completion_tokens: 600, total_tokens: 1600 }, - raw: { choices: [{ finish_reason: 'tool_calls' }] }, - }, 5000); - const completion = aggregateMessageCompletion(first, { - usage: { - prompt_tokens: null, - input_tokens: 2000, - completion_tokens: null, - output_tokens: 695, - total_tokens: null, - totalTokens: 2695, - }, - raw: { choices: [{ finish_reason: 'stop' }] }, - }, 2560); - - assert.deepEqual(completion, { - inputTokens: 3000, - outputTokens: 1295, - totalTokens: 4295, - durationMs: 7560, - finishReason: 'stop', - }, `${label}: completion metrics should cover every model call in the message`); - assert.equal(aggregateMessageCompletion(null, { - raw: { status: 'completed' }, - }, 1000).finishReason, '', `${label}: request lifecycle status is not a generation stop reason`); - assert.equal(aggregateMessageCompletion(first, { - finishReason: '', - }, 1000).finishReason, '', `${label}: an explicit empty terminal reason should clear an earlier call's stale reason`); - assert.deepEqual(buildMessageInfoPills({ - createdAt: Date.parse('2024-12-12T12:44:00Z'), - completion, - verbose: true, - locale: 'en-GB', - }), [ - { kind: 'sent', key: 'sp.message_info.sent', params: { time: '12/12/2024, 12:44 UTC' } }, - { kind: 'speed', key: 'sp.message_info.speed', params: { rate: '171.3' } }, - { kind: 'tokens', key: 'sp.message_info.tokens', params: { count: '1,295' } }, - { kind: 'duration', key: 'sp.message_info.duration', params: { seconds: '7.56' } }, - { kind: 'finish', key: 'sp.message_info.finish', params: { reason: 'stop' } }, - ], `${label}: verbose mode should add only available generation details`); - } + await withProcessTimeZone(MESSAGE_INFO_TEST_TIME_ZONE, async () => { + for (const [label, rel] of [ + ['chrome', 'src/chrome/src/message-info.js'], + ['firefox', 'src/firefox/src/message-info.js'], + ]) { + const { aggregateMessageCompletion, buildMessageInfoPills } = await import( + pathToFileURL(path.join(ROOT, rel)).href + ); + const first = aggregateMessageCompletion(null, { + usage: { prompt_tokens: 1000, completion_tokens: 600, total_tokens: 1600 }, + raw: { choices: [{ finish_reason: 'tool_calls' }] }, + }, 5000); + const completion = aggregateMessageCompletion(first, { + usage: { + prompt_tokens: null, + input_tokens: 2000, + completion_tokens: null, + output_tokens: 695, + total_tokens: null, + totalTokens: 2695, + }, + raw: { choices: [{ finish_reason: 'stop' }] }, + }, 2560); + + assert.deepEqual(completion, { + inputTokens: 3000, + outputTokens: 1295, + totalTokens: 4295, + durationMs: 7560, + finishReason: 'stop', + }, `${label}: completion metrics should cover every model call in the message`); + assert.equal(aggregateMessageCompletion(null, { + raw: { status: 'completed' }, + }, 1000).finishReason, '', `${label}: request lifecycle status is not a generation stop reason`); + assert.equal(aggregateMessageCompletion(first, { + finishReason: '', + }, 1000).finishReason, '', `${label}: an explicit empty terminal reason should clear an earlier call's stale reason`); + assert.deepEqual(buildMessageInfoPills({ + createdAt: MESSAGE_INFO_TEST_DATE, + completion, + verbose: true, + locale: 'en-GB', + }), [ + { kind: 'sent', key: 'sp.message_info.sent', params: { time: expectedMessageInfoTestTime() } }, + { kind: 'speed', key: 'sp.message_info.speed', params: { rate: '171.3' } }, + { kind: 'tokens', key: 'sp.message_info.tokens', params: { count: '1,295' } }, + { kind: 'duration', key: 'sp.message_info.duration', params: { seconds: '7.56' } }, + { kind: 'finish', key: 'sp.message_info.finish', params: { reason: 'stop' } }, + ], `${label}: verbose mode should add only available generation details`); + } + }); }); test('sidepanels reveal persisted message info while verbose gates completion details', () => { @@ -84268,8 +84299,8 @@ test('sidepanels reveal persisted message info while verbose gates completion de assert.match(panel, /import \{ buildMessageInfoPills \} from '\.\.\/message-info\.js';/, `${label}: sidepanel should adapt message info to the DOM`); assert.match( panel, - /function ensureMessageInfoElements\([\s\S]*?createElement\('button'\)[\s\S]*?aria-controls[\s\S]*?aria-expanded/, - `${label}: message info should expose a semantic button associated with its row`, + /function ensureMessageInfoElements\([\s\S]*?message-info-bar[\s\S]*?createElement\('button'\)[\s\S]*?aria-controls[\s\S]*?aria-expanded/, + `${label}: message info should expose one inline bar with a semantic button associated with its row`, ); assert.match(panel, /function bindMessageInfoToggle\([\s\S]*?toggle\.addEventListener\('click',[\s\S]*?msgEl\.addEventListener\('click'/, `${label}: the semantic button and bubble click should share the info toggle`); const bindMessageInfoToggle = panel.match(/function bindMessageInfoToggle\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; @@ -84282,8 +84313,22 @@ test('sidepanels reveal persisted message info while verbose gates completion de assert.doesNotMatch(createdAtReader, /Date\.now\(\)/, `${label}: unknown legacy timestamps must remain unknown`); assert.doesNotMatch(bindMessageInfoToggle, /setMessageCreatedAt\(/, `${label}: legacy restored messages must not invent a sent time while rebinding`); assert.match(panel, /setMessageCreatedAt\(msgEl, options\.createdAt \?\? Date\.now\(\)\)/, `${label}: newly-created messages should receive a real sent time`); - assert.match(css, /\.message-info \{[\s\S]*?\.message-info-pill \{/, `${label}: info rows and verbose pills should be styled`); - assert.match(css, /\.message-info-toggle \{[\s\S]*?\.message-info-toggle:focus-visible/, `${label}: the semantic toggle should have a visible keyboard focus treatment`); + assert.match( + css, + /\.message-info-bar \{[^}]*position: absolute;[^}]*inset-block-start: 100%;[^}]*display: none;[^}]*\}[\s\S]*?\.message\.message-info-open \{[^}]*margin-bottom: 25px;[^}]*\}[\s\S]*?\.message\.message-info-open > \.message-info-bar \{[^}]*display: flex;/, + `${label}: message details should appear outside the bubble only after it opens`, + ); + assert.match( + css, + /\.message-info \{[^}]*flex-wrap: nowrap;[^}]*overflow: hidden;[^}]*white-space: nowrap;/, + `${label}: normal and verbose message info should stay on one clipped line`, + ); + assert.match( + css, + /\.message-info-toggle \{[^}]*position: absolute;[^}]*inset: 0;[^}]*pointer-events: none;[^}]*\}[\s\S]*?\.message-info-toggle:focus-visible \{[^}]*outline:/, + `${label}: the icon-free semantic toggle should cover the bubble and retain visible keyboard focus`, + ); + assert.match(locale, /'sp\.message_info\.sent': 'sent \{time\}'/, `${label}: sent-time copy should not use parentheses`); for (const key of ['sent', 'speed', 'tokens', 'duration', 'finish', 'hint']) { assert.match(locale, new RegExp(`'sp\\.message_info\\.${key}'`), `${label}: ${key} message-info copy missing`); } @@ -84334,26 +84379,36 @@ test('message info toggles behaviorally through a semantic button, terminal repl assert.equal(messageCreatedAt(msgEl), 1734000000000, `${label}: sent time should persist on the message`); bindMessageInfoToggle(msgEl); - // A real toggle button with aria-controls, not a focusable bubble. + // A real, icon-free toggle button remains outside the hidden details bar, + // so closed messages stay reachable through native keyboard navigation. + const bar = msgEl.children.find((child) => child.className === 'message-info-bar'); + assert.ok(bar, `${label}: message details should have a separate inline bar`); const toggle = msgEl.children.find((child) => child.className === 'message-info-toggle'); assert.ok(toggle, `${label}: binding should create a semantic toggle button`); + assert.equal(toggle.parentNode, msgEl, `${label}: the closed toggle must remain outside the hidden details bar`); assert.equal(toggle.type, 'button', `${label}: the toggle should be a real button control`); + assert.equal(toggle.textContent, '', `${label}: the toggle should not render an info icon`); assert.match(String(toggle.attributes['aria-controls'] || ''), /^message-info-\d+$/, `${label}: the toggle should target the info row by id`); assert.equal(toggle.attributes['aria-expanded'], 'false', `${label}: the toggle should start collapsed`); assert.equal(toggle.attributes['aria-label'], 'sp.message_info.hint', `${label}: the toggle should expose its localized label`); assert.equal(msgEl.tabIndex, undefined, `${label}: the bubble itself must not become a focus target`); assert.equal(msgEl.attributes['aria-expanded'], undefined, `${label}: the bubble must not carry aria-expanded`); - // Bubble-click delegation still toggles (mouse convenience path). - msgEl.dispatch('click', { target: msgEl }); - assert.equal(msgEl.classList.contains('message-info-open'), true, `${label}: bubble click should open the info row`); + // Native Enter/Space activation on a focused button emits click; exercise + // that closed-state path directly before checking the mouse delegation. + toggle.dispatch('click', { target: toggle }); + assert.equal(msgEl.classList.contains('message-info-open'), true, `${label}: keyboard button activation should open the info row`); assert.equal(toggle.attributes['aria-expanded'], 'true', `${label}: the toggle should mirror the open state`); - const openRow = msgEl.children.find((child) => child.className === 'message-info'); + const openRow = bar.children.find((child) => child.className === 'message-info'); assert.ok(openRow, `${label}: opening should render the info row`); assert.equal(openRow.hidden, false, `${label}: the info row should be visible when open`); assert.equal(openRow.id, toggle.attributes['aria-controls'], `${label}: the row id should match the toggle target`); const sentPill = openRow.children.find((child) => child.className.includes('message-info-sent')); assert.ok(sentPill, `${label}: the sent-time pill should render`); + msgEl.dispatch('click', { target: msgEl }); + assert.equal(msgEl.classList.contains('message-info-open'), false, `${label}: a bubble click should close keyboard-opened info`); + msgEl.dispatch('click', { target: msgEl }); + assert.equal(msgEl.classList.contains('message-info-open'), true, `${label}: a second bubble click should reopen info`); // Live completion metadata reaches the datasets and renders in verbose mode. applyMessageCompletion(msgEl, { @@ -84383,11 +84438,13 @@ test('message info toggles behaviorally through a semantic button, terminal repl restored.dataset.messageFinishReason = 'stop'; restored.classList.add('message-info-open'); bindMessageInfoToggle(restored); + const restoredBar = restored.children.find((child) => child.className === 'message-info-bar'); + assert.ok(restoredBar, `${label}: restored details should retain their inline bar`); const restoredToggle = restored.children.find((child) => child.className === 'message-info-toggle'); assert.ok(restoredToggle, `${label}: restored messages should regain a toggle button`); assert.equal(restoredToggle.attributes['aria-expanded'], 'true', `${label}: restored open state should be preserved`); assert.equal(messageCreatedAt(restored), 1734000123456, `${label}: restored sent time should be retained`); - const restoredRow = restored.children.find((child) => child.className === 'message-info'); + const restoredRow = restoredBar.children.find((child) => child.className === 'message-info'); assert.ok(restoredRow, `${label}: restored open rows should render`); assert.equal(restoredRow.hidden, false, `${label}: restored open rows should stay visible`); assert.ok( @@ -84401,11 +84458,29 @@ test('message info toggles behaviorally through a semantic button, terminal repl assert.equal(restoredToggle.attributes['aria-expanded'], 'false', `${label}: the toggle should mirror the closed state`); assert.equal(restoredRow.hidden, true, `${label}: the row should hide when closed`); + // Histories serialized before the icon was removed should keep their + // toggle direct and move only the details into the hidden bar. + const preInline = fakeDomElement('message assistant'); + preInline.dataset.messageCreatedAt = String(1734000123456); + const preInlineToggle = fakeDomElement('message-info-toggle', 'button'); + const preInlineRow = fakeDomElement('message-info'); + preInline.appendChild(preInlineToggle); + preInline.appendChild(preInlineRow); + bindMessageInfoToggle(preInline); + const migratedBar = preInline.children.find((child) => child.className === 'message-info-bar'); + assert.ok(migratedBar, `${label}: pre-inline history should gain the shared info bar`); + assert.equal(preInlineToggle.parentNode, preInline, `${label}: the existing toggle should stay outside the hidden info bar`); + assert.equal(preInlineToggle.textContent, '', `${label}: a restored legacy toggle should lose its info icon`); + assert.equal(preInlineRow.parentNode, migratedBar, `${label}: the existing info row should move into the info bar`); + assert.equal(preInline.children.filter((child) => child.className === 'message-info-toggle').length, 1, `${label}: one direct keyboard toggle should remain`); + assert.equal(preInline.children.filter((child) => child.className === 'message-info').length, 0, `${label}: no direct legacy info row should remain`); + // Unknown legacy timestamps must stay unknown through rebinding. const legacy = fakeDomElement('message assistant'); legacy.dataset.messageFinishReason = 'stop'; bindMessageInfoToggle(legacy); assert.equal(messageCreatedAt(legacy), undefined, `${label}: legacy messages without a timestamp stay unknown`); + assert.equal(legacy.children.some((child) => child.className === 'message-info-bar'), false, `${label}: legacy messages without timestamps should not expose an info bar`); assert.equal(legacy.children.some((child) => child.className === 'message-info-toggle'), false, `${label}: legacy messages should not expose a toggle`); } @@ -84441,11 +84516,19 @@ test('message info toggles behaviorally through a semantic button, terminal repl if (name === 'title') this.title = ''; }, appendChild(child) { + if (child.parentNode) { + const oldIndex = child.parentNode.children.indexOf(child); + if (oldIndex >= 0) child.parentNode.children.splice(oldIndex, 1); + } child.parentNode = this; this.children.push(child); return child; }, insertBefore(child, reference) { + if (child.parentNode) { + const oldIndex = child.parentNode.children.indexOf(child); + if (oldIndex >= 0) child.parentNode.children.splice(oldIndex, 1); + } child.parentNode = this; const index = this.children.indexOf(reference); if (index < 0) this.children.push(child); @@ -84457,12 +84540,10 @@ test('message info toggles behaviorally through a semantic button, terminal repl items.forEach((child) => { child.parentNode = this; }); }, querySelector(selector) { - if (selector === ':scope > .message-info') { - return this.children.find((child) => child.className === 'message-info') || null; - } - if (selector === ':scope > .message-info-toggle') { - return this.children.find((child) => child.className === 'message-info-toggle') || null; - } + const directClass = selector.match(/^:scope > \.([\w-]+)$/)?.[1]; + if (directClass) return this.children.find((child) => ( + String(child.className || '').split(/\s+/).includes(directClass) + )) || null; return null; }, matches(selector) {