From d537fd3a0bbca0304daf73358c6595ce05136be6 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sat, 15 Aug 2026 13:19:50 +0800 Subject: [PATCH 1/5] feat: show message info on click --- src/chrome/src/agent/agent.js | 16 +++- src/chrome/src/message-info.js | 118 +++++++++++++++++++++++++++ src/chrome/src/ui/locales/ar.js | 6 ++ src/chrome/src/ui/locales/bn.js | 6 ++ src/chrome/src/ui/locales/de.js | 6 ++ src/chrome/src/ui/locales/en.js | 6 ++ src/chrome/src/ui/locales/es.js | 6 ++ src/chrome/src/ui/locales/fa.js | 6 ++ src/chrome/src/ui/locales/fr.js | 6 ++ src/chrome/src/ui/locales/he.js | 6 ++ src/chrome/src/ui/locales/hi.js | 6 ++ src/chrome/src/ui/locales/id.js | 6 ++ src/chrome/src/ui/locales/ja.js | 6 ++ src/chrome/src/ui/locales/ko.js | 6 ++ src/chrome/src/ui/locales/ms.js | 6 ++ src/chrome/src/ui/locales/nl.js | 6 ++ src/chrome/src/ui/locales/pl.js | 6 ++ src/chrome/src/ui/locales/pt.js | 6 ++ src/chrome/src/ui/locales/ru.js | 6 ++ src/chrome/src/ui/locales/th.js | 6 ++ src/chrome/src/ui/locales/tl.js | 6 ++ src/chrome/src/ui/locales/tr.js | 6 ++ src/chrome/src/ui/locales/uk.js | 6 ++ src/chrome/src/ui/locales/vi.js | 6 ++ src/chrome/src/ui/locales/zh.js | 6 ++ src/chrome/src/ui/sidepanel.js | 136 ++++++++++++++++++++++++++++++- src/chrome/styles/sidepanel.css | 45 ++++++++++ src/firefox/src/agent/agent.js | 16 +++- src/firefox/src/message-info.js | 118 +++++++++++++++++++++++++++ src/firefox/src/ui/locales/ar.js | 6 ++ src/firefox/src/ui/locales/bn.js | 6 ++ src/firefox/src/ui/locales/de.js | 6 ++ src/firefox/src/ui/locales/en.js | 6 ++ src/firefox/src/ui/locales/es.js | 6 ++ src/firefox/src/ui/locales/fa.js | 6 ++ src/firefox/src/ui/locales/fr.js | 6 ++ src/firefox/src/ui/locales/he.js | 6 ++ src/firefox/src/ui/locales/hi.js | 6 ++ src/firefox/src/ui/locales/id.js | 6 ++ src/firefox/src/ui/locales/ja.js | 6 ++ src/firefox/src/ui/locales/ko.js | 6 ++ src/firefox/src/ui/locales/ms.js | 6 ++ src/firefox/src/ui/locales/nl.js | 6 ++ src/firefox/src/ui/locales/pl.js | 6 ++ src/firefox/src/ui/locales/pt.js | 6 ++ src/firefox/src/ui/locales/ru.js | 6 ++ src/firefox/src/ui/locales/th.js | 6 ++ src/firefox/src/ui/locales/tl.js | 6 ++ src/firefox/src/ui/locales/tr.js | 6 ++ src/firefox/src/ui/locales/uk.js | 6 ++ src/firefox/src/ui/locales/vi.js | 6 ++ src/firefox/src/ui/locales/zh.js | 6 ++ src/firefox/src/ui/sidepanel.js | 136 ++++++++++++++++++++++++++++++- src/firefox/styles/sidepanel.css | 45 ++++++++++ test/run.js | 108 ++++++++++++++++++++++++ 55 files changed, 1010 insertions(+), 4 deletions(-) create mode 100644 src/chrome/src/message-info.js create mode 100644 src/firefox/src/message-info.js diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index ae34f98fb..fd89c3510 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -2,6 +2,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMo import { validateToolArguments } from './tool-arguments.js'; import { isSessionQuotaError, serializeConversationForSession, SESSION_CONVERSATION_BUDGET_BYTES, SESSION_CONVERSATION_RETRY_BUDGET_BYTES } from './conversation-persistence.js'; import { formatErrorMessage } from '../error-format.js'; +import { aggregateMessageCompletion } from '../message-info.js'; import { handleDoneJson } from './cloud-output.js'; import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js'; import { STANDARD_TOOL_RESULT_CHARS, createReadCompletenessState, isCommunicationThreadContext, normalizeReadScope, readCompletenessBlock, readCompletenessLimitation, readCompletenessMadeProgress, readWindowLimits, recordReadCompleteness, requirePlannerReadCompleteness, requiresCompleteThreadRead } from './read-completeness.js'; @@ -24334,6 +24335,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let runId = null; let finalResponse = ''; + let messageCompletion = null; let _traceStatus = 'done'; // updated on early exits let askStreamingTraceWrite = Promise.resolve(); let shouldOrderInteractiveAskTrace = false; @@ -24515,7 +24517,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); }; - const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { + const chatMainTurnRaw = async (chatMessages, chatOptions, requestContext) => { const decision = this._interactiveAskStreamingDecision( provider, mode, @@ -24610,6 +24612,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } }; + const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { + const startedAt = Date.now(); + const result = await chatMainTurnRaw(chatMessages, chatOptions, requestContext); + messageCompletion = aggregateMessageCompletion( + messageCompletion, + result, + Date.now() - startedAt, + ); + onUpdate('message_info', messageCompletion); + return result; + }; + if (!runId) { runId = await this._startTraceRun( tabId, userMessage, mode, provider, null, runOptions, diff --git a/src/chrome/src/message-info.js b/src/chrome/src/message-info.js new file mode 100644 index 000000000..ef7f31e86 --- /dev/null +++ b/src/chrome/src/message-info.js @@ -0,0 +1,118 @@ +function formatSentTime(createdAt, locale) { + const value = Number(createdAt); + if (!Number.isFinite(value) || value <= 0) return ''; + try { + return new Intl.DateTimeFormat(locale || undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + timeZoneName: 'short', + }).format(new Date(value)); + } catch { + return new Date(value).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + } +} + +function finiteCount(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number) && number > 0) return Math.floor(number); + } + return 0; +} + +function finishReason(result) { + const raw = result?.raw || {}; + return String( + result?.finishReason + ?? raw?.choices?.[0]?.finish_reason + ?? raw?.stop_reason + ?? raw?.stopReason + ?? raw?.status + ?? '', + ).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); +} + +function formatNumber(value, locale, maximumFractionDigits = 0) { + try { + return new Intl.NumberFormat(locale || undefined, { + maximumFractionDigits, + }).format(value); + } catch { + return Number(value).toFixed(maximumFractionDigits).replace(/\.0+$/, ''); + } +} + +export function aggregateMessageCompletion(current, result, durationMs) { + const previous = current || {}; + const usage = result?.usage || {}; + const inputTokens = finiteCount( + usage.prompt_tokens, + usage.input_tokens, + usage.promptTokens, + usage.inputTokens, + ); + const outputTokens = finiteCount( + usage.completion_tokens, + usage.output_tokens, + usage.completionTokens, + usage.outputTokens, + ); + const reportedTotal = finiteCount(usage.total_tokens, usage.totalTokens); + const elapsed = Number(durationMs); + return { + inputTokens: finiteCount(previous.inputTokens) + inputTokens, + outputTokens: finiteCount(previous.outputTokens) + outputTokens, + totalTokens: finiteCount(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), + durationMs: finiteCount(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), + finishReason: finishReason(result) || String(previous.finishReason || '').slice(0, 80), + }; +} + +export function buildMessageInfoPills({ createdAt, completion = {}, verbose = false, locale } = {}) { + const time = formatSentTime(createdAt, locale); + if (!time) return []; + const pills = [{ + kind: 'sent', + key: 'sp.message_info.sent', + params: { time }, + }]; + if (!verbose) return pills; + const outputTokens = finiteCount(completion.outputTokens); + const displayedTokens = outputTokens || finiteCount(completion.totalTokens); + const durationMs = finiteCount(completion.durationMs); + if (outputTokens && durationMs) { + pills.push({ + kind: 'speed', + key: 'sp.message_info.speed', + params: { rate: formatNumber((outputTokens * 1000) / durationMs, locale, 2) }, + }); + } + if (displayedTokens) { + pills.push({ + kind: 'tokens', + key: 'sp.message_info.tokens', + params: { count: formatNumber(displayedTokens, locale) }, + }); + } + if (durationMs) { + pills.push({ + kind: 'duration', + key: 'sp.message_info.duration', + params: { seconds: formatNumber(durationMs / 1000, locale, 2) }, + }); + } + const reason = String(completion.finishReason || '').trim().slice(0, 80); + if (reason) { + pills.push({ + kind: 'finish', + key: 'sp.message_info.finish', + params: { reason }, + }); + } + return pills; +} diff --git a/src/chrome/src/ui/locales/ar.js b/src/chrome/src/ui/locales/ar.js index 2190015c2..c426859c3 100644 --- a/src/chrome/src/ui/locales/ar.js +++ b/src/chrome/src/ui/locales/ar.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'انقطع بث الاستجابة؛ تتم إعادة محاولة دور Ask هذا بدون بث.', 'sp.providers.no_setup_group': 'لا يتطلب إعدادًا', diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 65ae92caf..4945fa299 100644 --- a/src/chrome/src/ui/locales/bn.js +++ b/src/chrome/src/ui/locales/bn.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'প্রতিক্রিয়া স্ট্রিম বাধাগ্রস্ত হয়েছে; স্ট্রিমিং ছাড়া এই Ask পালাটি আবার চেষ্টা করা হচ্ছে।', 'sp.providers.no_setup_group': "কোন সেটআপ প্রয়োজন", 'sp.providers.no_setup': "কোনো সেটআপ নেই", diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index 743a43be8..c3be501f2 100644 --- a/src/chrome/src/ui/locales/de.js +++ b/src/chrome/src/ui/locales/de.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Der Antwortstream wurde unterbrochen; dieser Ask-Durchgang wird ohne Streaming erneut versucht.', 'sp.providers.no_setup_group': 'Keine Einrichtung erforderlich', diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index 19f0304d8..bd1b12a58 100644 --- a/src/chrome/src/ui/locales/en.js +++ b/src/chrome/src/ui/locales/en.js @@ -2,6 +2,12 @@ import apocalypseModeCopy from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'Response streaming was interrupted; retrying this Ask turn without streaming.', 'sp.providers.no_setup_group': 'No setup required', 'sp.providers.no_setup': 'No setup', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index 79c88890f..983ce223a 100644 --- a/src/chrome/src/ui/locales/es.js +++ b/src/chrome/src/ui/locales/es.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Se interrumpió la transmisión de la respuesta; reintentando este turno de Ask sin transmisión.', 'sp.providers.no_setup_group': 'Sin configuración', diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index 0f8fefc13..3494d0790 100644 --- a/src/chrome/src/ui/locales/fa.js +++ b/src/chrome/src/ui/locales/fa.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'جریان پاسخ قطع شد؛ این نوبت Ask بدون پخش جریانی دوباره امتحان می‌شود.', 'sp.providers.no_setup_group': "بدون نیاز به راه اندازی", 'sp.providers.no_setup': "بدون راه اندازی", diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index 0559db3a0..47f700907 100644 --- a/src/chrome/src/ui/locales/fr.js +++ b/src/chrome/src/ui/locales/fr.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Le flux de réponse a été interrompu ; nouvelle tentative de ce tour Ask sans streaming.', 'sp.providers.no_setup_group': 'Sans configuration', diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index f75513826..6ae05a57f 100644 --- a/src/chrome/src/ui/locales/he.js +++ b/src/chrome/src/ui/locales/he.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'הזרמת התשובה נקטעה; מתבצע ניסיון חוזר לתור Ask הזה ללא הזרמה.', 'sp.providers.no_setup_group': 'ללא הגדרה', diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 2522f4a55..c6ffa9366 100644 --- a/src/chrome/src/ui/locales/hi.js +++ b/src/chrome/src/ui/locales/hi.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'प्रतिक्रिया स्ट्रीम बाधित हुई; इस Ask टर्न को बिना स्ट्रीमिंग के फिर से आज़माया जा रहा है।', 'sp.providers.no_setup_group': "किसी सेटअप की आवश्यकता नहीं है", 'sp.providers.no_setup': "कोई सेटअप नहीं", diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index 7b04c40f8..8ca79ea3e 100644 --- a/src/chrome/src/ui/locales/id.js +++ b/src/chrome/src/ui/locales/id.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Streaming respons terputus; mencoba kembali giliran Ask ini tanpa streaming.', 'sp.providers.no_setup_group': 'Tanpa penyiapan', diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index 63e3f975e..52aa3ac4b 100644 --- a/src/chrome/src/ui/locales/ja.js +++ b/src/chrome/src/ui/locales/ja.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': '応答ストリームが中断されました。この Ask ターンをストリーミングなしで再試行します。', 'sp.providers.no_setup_group': '設定不要', diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index 6afba1238..9310766b6 100644 --- a/src/chrome/src/ui/locales/ko.js +++ b/src/chrome/src/ui/locales/ko.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': '응답 스트리밍이 중단되었습니다. 이 Ask 요청을 스트리밍 없이 다시 시도합니다.', 'sp.providers.no_setup_group': '설정 필요 없음', diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index 89e328073..de91ac17f 100644 --- a/src/chrome/src/ui/locales/ms.js +++ b/src/chrome/src/ui/locales/ms.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Penstriman respons terganggu; mencuba semula giliran Ask ini tanpa penstriman.', 'sp.providers.no_setup_group': 'Tanpa persediaan', diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index cbed65105..d2f047efc 100644 --- a/src/chrome/src/ui/locales/nl.js +++ b/src/chrome/src/ui/locales/nl.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'De antwoordstream is onderbroken; deze Ask-beurt wordt opnieuw geprobeerd zonder streaming.', 'sp.providers.no_setup_group': 'Geen configuratie nodig', diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index c16b853a0..9f7408968 100644 --- a/src/chrome/src/ui/locales/pl.js +++ b/src/chrome/src/ui/locales/pl.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Strumieniowanie odpowiedzi zostało przerwane; ponawiam tę turę Ask bez strumieniowania.', 'sp.providers.no_setup_group': 'Bez konfiguracji', diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 1523efed2..900738c89 100644 --- a/src/chrome/src/ui/locales/pt.js +++ b/src/chrome/src/ui/locales/pt.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'A transmissão da resposta foi interrompida; tentando novamente esta interação Ask sem transmissão.', 'sp.providers.no_setup_group': "Nenhuma configuração necessária", 'sp.providers.no_setup': "Sem configuração", diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 37049cbfc..ac01cd11c 100644 --- a/src/chrome/src/ui/locales/ru.js +++ b/src/chrome/src/ui/locales/ru.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Поток ответа был прерван; этот запрос Ask повторяется без потоковой передачи.', 'sp.providers.no_setup_group': 'Без настройки', diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index f01d11e7c..9562f7a5a 100644 --- a/src/chrome/src/ui/locales/th.js +++ b/src/chrome/src/ui/locales/th.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'การสตรีมคำตอบถูกขัดจังหวะ กำลังลอง Ask รอบนี้อีกครั้งโดยไม่ใช้สตรีม', 'sp.providers.no_setup_group': 'ไม่ต้องตั้งค่า', diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index 0e2972da9..a9d32e53f 100644 --- a/src/chrome/src/ui/locales/tl.js +++ b/src/chrome/src/ui/locales/tl.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Naputol ang pag-stream ng tugon; sinusubukang muli ang Ask turn na ito nang walang streaming.', 'sp.providers.no_setup_group': 'Walang setup', diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index a62c62efc..e0cf39bb3 100644 --- a/src/chrome/src/ui/locales/tr.js +++ b/src/chrome/src/ui/locales/tr.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Yanıt akışı kesildi; bu Ask turu akış olmadan yeniden deneniyor.', 'sp.providers.no_setup_group': 'Kurulum gerektirmez', diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index a396030d0..decd9ac80 100644 --- a/src/chrome/src/ui/locales/uk.js +++ b/src/chrome/src/ui/locales/uk.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Потік відповіді перервано; цей запит Ask повторюється без потокової передачі.', 'sp.providers.no_setup_group': 'Без налаштування', diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index 6d528f7ac..86059253c 100644 --- a/src/chrome/src/ui/locales/vi.js +++ b/src/chrome/src/ui/locales/vi.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'Luồng phản hồi bị gián đoạn; đang thử lại lượt Ask này mà không phát trực tuyến.', 'sp.providers.no_setup_group': "Không cần thiết lập", 'sp.providers.no_setup': "Không cần thiết lập", diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index 27df79cc9..c8387e9bc 100644 --- a/src/chrome/src/ui/locales/zh.js +++ b/src/chrome/src/ui/locales/zh.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': '响应流已中断;正在以非流式方式重试本次 Ask。', 'sp.providers.no_setup_group': '无需配置', diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 9b3f89353..c26edb100 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -26,6 +26,7 @@ import { claimRunError } from './run-error-dedupe.js'; import { RUN_CAPTURE_START_ERROR_PREFIX } from '../run-capture.js'; import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; import { formatErrorMessage } from '../error-format.js'; +import { buildMessageInfoPills } from '../message-info.js'; import { escapeHtml } from './utils.js'; import { isBackgroundConnectionError, @@ -2390,7 +2391,7 @@ function extractChatHistoryMessages(root = messagesEl) { text: normalizeHistoryText(historyTextFromElement(textEl, { markdown: format === 'markdown' })), format, index, - createdAt: Date.now(), + createdAt: messageCreatedAt(msgEl), ...(attachments.length ? { attachments } : {}), }; }).filter((message) => message.text || message.attachments?.length); @@ -4136,6 +4137,7 @@ async function init() { if (changes.verboseMode) { verboseMode = changes.verboseMode.newValue; if (verboseBtn) verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); } if (changes.alwaysAllowApiMutations) { alwaysAllowApiMutations = changes.alwaysAllowApiMutations.newValue === true; @@ -4190,6 +4192,7 @@ if (verboseBtn) { // Normal click → toggle verbose mode verboseMode = !verboseMode; verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); await chrome.storage.local.set({ verboseMode }).catch(() => {}); }); } @@ -6134,6 +6137,7 @@ function renderAgentErrorUpdate(data, tabId = currentTabId, requestId = '', opti function rebindRestoredMessageControls() { restoreStagedScreenshotAttachments(); rebindCopyButtons(); + rebindMessageInfoToggles(); rebindCompactStepDetailsToggles(); rebindScreenshotSaveButtons(); rebindRetryButtons(); @@ -7418,6 +7422,7 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { if (command.value === '/verbose') { verboseMode = !verboseMode; if (verboseBtn) verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); await chrome.storage.local.set({ verboseMode }).catch(() => {}); if (currentTabId !== tabId) return ''; showComposerToast(systemHtml(verboseMode @@ -8871,6 +8876,10 @@ function handleAgentUpdateMessage(msg) { reportTrailingRunCaptureError({ kind: data?.kind }, new Error(data?.message || 'unknown error'), eventTabId); break; + case 'message_info': + applyMessageCompletion(eventAssistantEl || currentAssistantEl, data); + break; + case 'error': hideActivity(); if (currentAssistantEl) markLastStepFailed(); @@ -8921,6 +8930,7 @@ function handleAgentUpdateMessage(msg) { break; case 'run_complete': + setMessageCreatedAt(eventAssistantEl || currentAssistantEl, data?.endedAt, { replace: true }); if (currentAssistantEl) finalizeSteps(currentAssistantEl); reconcileRunMessageAttachmentState( eventTabId, @@ -10422,6 +10432,125 @@ function messageAttachmentMetadata(msgEl) { }, item.dataset.deliveryState)); } +function messageCreatedAt(msgEl) { + const value = Number(msgEl?.dataset?.messageCreatedAt); + return Number.isFinite(value) && value > 0 ? value : Date.now(); +} + +function setMessageCreatedAt(msgEl, value = Date.now(), { replace = false } = {}) { + if (!msgEl) return null; + const existing = Number(msgEl.dataset.messageCreatedAt); + if (!replace && Number.isFinite(existing) && existing > 0) return existing; + const candidate = Number(value); + const next = Number.isFinite(candidate) && candidate > 0 + ? candidate + : (Number.isFinite(existing) && existing > 0 ? existing : Date.now()); + msgEl.dataset.messageCreatedAt = String(Math.round(next)); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + return next; +} + +function messageCompletionFromElement(msgEl) { + return { + inputTokens: Number(msgEl?.dataset?.messageInputTokens) || 0, + outputTokens: Number(msgEl?.dataset?.messageOutputTokens) || 0, + totalTokens: Number(msgEl?.dataset?.messageTotalTokens) || 0, + durationMs: Number(msgEl?.dataset?.messageDurationMs) || 0, + finishReason: String(msgEl?.dataset?.messageFinishReason || ''), + }; +} + +function renderMessageInfo(msgEl) { + if (!msgEl) return; + let row = msgEl.querySelector(':scope > .message-info'); + if (!row) { + row = document.createElement('div'); + row.className = 'message-info'; + row.setAttribute('role', 'status'); + msgEl.appendChild(row); + } + const pills = buildMessageInfoPills({ + createdAt: messageCreatedAt(msgEl), + completion: messageCompletionFromElement(msgEl), + verbose: verboseMode, + locale: getLocale(), + }); + row.replaceChildren(...pills.map((pill) => { + const item = document.createElement('span'); + item.className = pill.kind === 'sent' + ? 'message-info-item message-info-sent' + : `message-info-item message-info-pill message-info-${pill.kind}`; + item.textContent = t(pill.key, pill.params); + return item; + })); + row.hidden = !msgEl.classList.contains('message-info-open') || pills.length === 0; +} + +function messageInfoClickIsInteractive(target) { + return !!target?.closest?.( + 'a, button, input, textarea, select, summary, [role="button"], [contenteditable="true"]', + ); +} + +function toggleMessageInfo(msgEl) { + const open = msgEl.classList.toggle('message-info-open'); + msgEl.setAttribute('aria-expanded', String(open)); + renderMessageInfo(msgEl); + schedulePersist(); +} + +function bindMessageInfoToggle(msgEl) { + if (!msgEl?.matches?.('.message.user, .message.assistant')) return; + setMessageCreatedAt(msgEl); + msgEl.tabIndex = 0; + msgEl.title = t('sp.message_info.hint'); + msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + if (msgEl.__wbMessageInfoBound) return; + msgEl.__wbMessageInfoBound = true; + msgEl.addEventListener('click', (event) => { + if (messageInfoClickIsInteractive(event.target)) return; + toggleMessageInfo(msgEl); + }); + msgEl.addEventListener('keydown', (event) => { + if (event.target !== msgEl || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + toggleMessageInfo(msgEl); + }); +} + +function rebindMessageInfoToggles() { + messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant') + .forEach(bindMessageInfoToggle); +} + +function applyMessageCompletion(msgEl, completion = {}) { + if (!msgEl) return; + const values = { + messageInputTokens: completion.inputTokens, + messageOutputTokens: completion.outputTokens, + messageTotalTokens: completion.totalTokens, + messageDurationMs: completion.durationMs, + }; + for (const [key, value] of Object.entries(values)) { + const number = Number(value); + msgEl.dataset[key] = String(Number.isFinite(number) && number >= 0 ? Math.round(number) : 0); + } + msgEl.dataset.messageFinishReason = String(completion.finishReason || '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, 80); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + schedulePersist(); +} + +function refreshOpenMessageInfoRows() { + messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { + msgEl.title = t('sp.message_info.hint'); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + }); +} + function addMessage(role, content, options = {}) { const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; @@ -10472,6 +10601,10 @@ function addMessage(role, content, options = {}) { } else { messagesEl.appendChild(msgEl); } + if (role === 'user' || role === 'assistant') { + setMessageCreatedAt(msgEl, options.createdAt); + bindMessageInfoToggle(msgEl); + } if (role === 'error' && options.retryPayload && !textEl.classList.contains('cost-allowance-error')) { @@ -12469,6 +12602,7 @@ document.addEventListener('wb-locale-changed', () => { if (slashCommandMatches.length) renderSlashCommandAutocomplete(); renderQueuedComposerMessages(); syncSelectionScopeUi(); + refreshOpenMessageInfoRows(); void loadProviders(); }); diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index eec9b9a0c..2455b599c 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -1378,6 +1378,17 @@ body { width: 95%; } +.message.user, +.message.assistant { + cursor: pointer; +} + +.message.user:focus-visible, +.message.assistant:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + .message.assistant.assistant-awaiting-content { display: none; } @@ -1449,6 +1460,40 @@ body { border-bottom-left-radius: 4px; } +.message-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 5px; + padding: 0 4px; + color: var(--text-secondary); + font-size: 10px; + font-variant-numeric: tabular-nums; + line-height: 1.35; +} + +.message.user .message-info { + align-self: flex-end; + justify-content: flex-end; +} + +.message-info[hidden] { + display: none; +} + +.message-info-pill { + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-primary); +} + +.message-info-sent { + opacity: 0.9; +} + .message.system .message-content { background: var(--accent-dim); border: 1px solid rgba(108, 99, 255, 0.2); diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 7e0dcbfe1..18dca8897 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -2,6 +2,7 @@ import { AGENT_TOOLS, AGENT_TOOL_NAMES, RESERVED_AGENT_TOOL_NAMES, getToolsForMo import { validateToolArguments } from './tool-arguments.js'; import { isSessionQuotaError, serializeConversationForSession, SESSION_CONVERSATION_BUDGET_BYTES, SESSION_CONVERSATION_RETRY_BUDGET_BYTES } from './conversation-persistence.js'; import { formatErrorMessage } from '../error-format.js'; +import { aggregateMessageCompletion } from '../message-info.js'; import { handleDoneJson } from './cloud-output.js'; import { applyReadPageWindow, fitReadPageWindowResult, isReadPageWindowResult } from './read-page-window.js'; import { STANDARD_TOOL_RESULT_CHARS, createReadCompletenessState, isCommunicationThreadContext, normalizeReadScope, readCompletenessBlock, readCompletenessLimitation, readCompletenessMadeProgress, readWindowLimits, recordReadCompleteness, requirePlannerReadCompleteness, requiresCompleteThreadRead } from './read-completeness.js'; @@ -18925,6 +18926,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d let runId = null; let finalResponse = ''; + let messageCompletion = null; let _traceStatus = 'done'; let askStreamingTraceWrite = Promise.resolve(); let shouldOrderInteractiveAskTrace = false; @@ -19097,7 +19099,7 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d ); }; - const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { + const chatMainTurnRaw = async (chatMessages, chatOptions, requestContext) => { const decision = this._interactiveAskStreamingDecision( provider, mode, @@ -19192,6 +19194,18 @@ Rules: no prose intro, no conclusion, no "this screenshot shows...", no layout d } }; + const chatMainTurn = async (chatMessages, chatOptions, requestContext) => { + const startedAt = Date.now(); + const result = await chatMainTurnRaw(chatMessages, chatOptions, requestContext); + messageCompletion = aggregateMessageCompletion( + messageCompletion, + result, + Date.now() - startedAt, + ); + onUpdate('message_info', messageCompletion); + return result; + }; + if (!runId) { runId = await this._startTraceRun( tabId, userMessage, mode, provider, null, runOptions, diff --git a/src/firefox/src/message-info.js b/src/firefox/src/message-info.js new file mode 100644 index 000000000..ef7f31e86 --- /dev/null +++ b/src/firefox/src/message-info.js @@ -0,0 +1,118 @@ +function formatSentTime(createdAt, locale) { + const value = Number(createdAt); + if (!Number.isFinite(value) || value <= 0) return ''; + try { + return new Intl.DateTimeFormat(locale || undefined, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC', + timeZoneName: 'short', + }).format(new Date(value)); + } catch { + return new Date(value).toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; + } +} + +function finiteCount(...values) { + for (const value of values) { + const number = Number(value); + if (Number.isFinite(number) && number > 0) return Math.floor(number); + } + return 0; +} + +function finishReason(result) { + const raw = result?.raw || {}; + return String( + result?.finishReason + ?? raw?.choices?.[0]?.finish_reason + ?? raw?.stop_reason + ?? raw?.stopReason + ?? raw?.status + ?? '', + ).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); +} + +function formatNumber(value, locale, maximumFractionDigits = 0) { + try { + return new Intl.NumberFormat(locale || undefined, { + maximumFractionDigits, + }).format(value); + } catch { + return Number(value).toFixed(maximumFractionDigits).replace(/\.0+$/, ''); + } +} + +export function aggregateMessageCompletion(current, result, durationMs) { + const previous = current || {}; + const usage = result?.usage || {}; + const inputTokens = finiteCount( + usage.prompt_tokens, + usage.input_tokens, + usage.promptTokens, + usage.inputTokens, + ); + const outputTokens = finiteCount( + usage.completion_tokens, + usage.output_tokens, + usage.completionTokens, + usage.outputTokens, + ); + const reportedTotal = finiteCount(usage.total_tokens, usage.totalTokens); + const elapsed = Number(durationMs); + return { + inputTokens: finiteCount(previous.inputTokens) + inputTokens, + outputTokens: finiteCount(previous.outputTokens) + outputTokens, + totalTokens: finiteCount(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), + durationMs: finiteCount(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), + finishReason: finishReason(result) || String(previous.finishReason || '').slice(0, 80), + }; +} + +export function buildMessageInfoPills({ createdAt, completion = {}, verbose = false, locale } = {}) { + const time = formatSentTime(createdAt, locale); + if (!time) return []; + const pills = [{ + kind: 'sent', + key: 'sp.message_info.sent', + params: { time }, + }]; + if (!verbose) return pills; + const outputTokens = finiteCount(completion.outputTokens); + const displayedTokens = outputTokens || finiteCount(completion.totalTokens); + const durationMs = finiteCount(completion.durationMs); + if (outputTokens && durationMs) { + pills.push({ + kind: 'speed', + key: 'sp.message_info.speed', + params: { rate: formatNumber((outputTokens * 1000) / durationMs, locale, 2) }, + }); + } + if (displayedTokens) { + pills.push({ + kind: 'tokens', + key: 'sp.message_info.tokens', + params: { count: formatNumber(displayedTokens, locale) }, + }); + } + if (durationMs) { + pills.push({ + kind: 'duration', + key: 'sp.message_info.duration', + params: { seconds: formatNumber(durationMs / 1000, locale, 2) }, + }); + } + const reason = String(completion.finishReason || '').trim().slice(0, 80); + if (reason) { + pills.push({ + kind: 'finish', + key: 'sp.message_info.finish', + params: { reason }, + }); + } + return pills; +} diff --git a/src/firefox/src/ui/locales/ar.js b/src/firefox/src/ui/locales/ar.js index bce35c0e6..b9d30de2e 100644 --- a/src/firefox/src/ui/locales/ar.js +++ b/src/firefox/src/ui/locales/ar.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'انقطع بث الاستجابة؛ تتم إعادة محاولة دور Ask هذا بدون بث.', 'sp.providers.no_setup_group': 'لا يتطلب إعدادًا', diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index de2e6dab2..e438ca385 100644 --- a/src/firefox/src/ui/locales/bn.js +++ b/src/firefox/src/ui/locales/bn.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'প্রতিক্রিয়া স্ট্রিম বাধাগ্রস্ত হয়েছে; স্ট্রিমিং ছাড়া এই Ask পালাটি আবার চেষ্টা করা হচ্ছে।', 'sp.providers.no_setup_group': "কোন সেটআপ প্রয়োজন", 'sp.providers.no_setup': "কোনো সেটআপ নেই", diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index 7bee5c0c0..635768383 100644 --- a/src/firefox/src/ui/locales/de.js +++ b/src/firefox/src/ui/locales/de.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Der Antwortstream wurde unterbrochen; dieser Ask-Durchgang wird ohne Streaming erneut versucht.', 'sp.providers.no_setup_group': 'Keine Einrichtung erforderlich', diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index bbbc56682..1528326a5 100644 --- a/src/firefox/src/ui/locales/en.js +++ b/src/firefox/src/ui/locales/en.js @@ -2,6 +2,12 @@ import apocalypseModeCopy from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'Response streaming was interrupted; retrying this Ask turn without streaming.', 'sp.providers.no_setup_group': 'No setup required', 'sp.providers.no_setup': 'No setup', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index bf1e9fdee..2f14749d6 100644 --- a/src/firefox/src/ui/locales/es.js +++ b/src/firefox/src/ui/locales/es.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Se interrumpió la transmisión de la respuesta; reintentando este turno de Ask sin transmisión.', 'sp.providers.no_setup_group': 'Sin configuración', diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index d726a4c8c..a9e0e46c4 100644 --- a/src/firefox/src/ui/locales/fa.js +++ b/src/firefox/src/ui/locales/fa.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'جریان پاسخ قطع شد؛ این نوبت Ask بدون پخش جریانی دوباره امتحان می‌شود.', 'sp.providers.no_setup_group': "بدون نیاز به راه اندازی", 'sp.providers.no_setup': "بدون راه اندازی", diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index f626260cb..ec2243f85 100644 --- a/src/firefox/src/ui/locales/fr.js +++ b/src/firefox/src/ui/locales/fr.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Le flux de réponse a été interrompu ; nouvelle tentative de ce tour Ask sans streaming.', 'sp.providers.no_setup_group': 'Sans configuration', diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index 660513fa7..351d93c43 100644 --- a/src/firefox/src/ui/locales/he.js +++ b/src/firefox/src/ui/locales/he.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'הזרמת התשובה נקטעה; מתבצע ניסיון חוזר לתור Ask הזה ללא הזרמה.', 'sp.providers.no_setup_group': 'ללא הגדרה', diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index a91b3b2e7..e3f7953c6 100644 --- a/src/firefox/src/ui/locales/hi.js +++ b/src/firefox/src/ui/locales/hi.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'प्रतिक्रिया स्ट्रीम बाधित हुई; इस Ask टर्न को बिना स्ट्रीमिंग के फिर से आज़माया जा रहा है।', 'sp.providers.no_setup_group': "किसी सेटअप की आवश्यकता नहीं है", 'sp.providers.no_setup': "कोई सेटअप नहीं", diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index 6242c798b..9fbad5732 100644 --- a/src/firefox/src/ui/locales/id.js +++ b/src/firefox/src/ui/locales/id.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Streaming respons terputus; mencoba kembali giliran Ask ini tanpa streaming.', 'sp.providers.no_setup_group': 'Tanpa penyiapan', diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 64e1dcba1..e95b56e0a 100644 --- a/src/firefox/src/ui/locales/ja.js +++ b/src/firefox/src/ui/locales/ja.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': '応答ストリームが中断されました。この Ask ターンをストリーミングなしで再試行します。', 'sp.providers.no_setup_group': '設定不要', diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index fc374be12..713395d3c 100644 --- a/src/firefox/src/ui/locales/ko.js +++ b/src/firefox/src/ui/locales/ko.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': '응답 스트리밍이 중단되었습니다. 이 Ask 요청을 스트리밍 없이 다시 시도합니다.', 'sp.providers.no_setup_group': '설정 필요 없음', diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index 0fcc7e8ff..0fa09d649 100644 --- a/src/firefox/src/ui/locales/ms.js +++ b/src/firefox/src/ui/locales/ms.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Penstriman respons terganggu; mencuba semula giliran Ask ini tanpa penstriman.', 'sp.providers.no_setup_group': 'Tanpa persediaan', diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index dade456a4..637f9a4fe 100644 --- a/src/firefox/src/ui/locales/nl.js +++ b/src/firefox/src/ui/locales/nl.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'De antwoordstream is onderbroken; deze Ask-beurt wordt opnieuw geprobeerd zonder streaming.', 'sp.providers.no_setup_group': 'Geen configuratie nodig', diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index ee82c0ca6..a79231315 100644 --- a/src/firefox/src/ui/locales/pl.js +++ b/src/firefox/src/ui/locales/pl.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Strumieniowanie odpowiedzi zostało przerwane; ponawiam tę turę Ask bez strumieniowania.', 'sp.providers.no_setup_group': 'Bez konfiguracji', diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index 375ae7d65..c7aa36388 100644 --- a/src/firefox/src/ui/locales/pt.js +++ b/src/firefox/src/ui/locales/pt.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'A transmissão da resposta foi interrompida; tentando novamente esta interação Ask sem transmissão.', 'sp.providers.no_setup_group': "Nenhuma configuração necessária", 'sp.providers.no_setup': "Sem configuração", diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index 3ae873e43..756f0ac00 100644 --- a/src/firefox/src/ui/locales/ru.js +++ b/src/firefox/src/ui/locales/ru.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Поток ответа был прерван; этот запрос Ask повторяется без потоковой передачи.', 'sp.providers.no_setup_group': 'Без настройки', diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index 2a1157d86..b61d09ff2 100644 --- a/src/firefox/src/ui/locales/th.js +++ b/src/firefox/src/ui/locales/th.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'การสตรีมคำตอบถูกขัดจังหวะ กำลังลอง Ask รอบนี้อีกครั้งโดยไม่ใช้สตรีม', 'sp.providers.no_setup_group': 'ไม่ต้องตั้งค่า', diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index 1c2d41182..2c78504d6 100644 --- a/src/firefox/src/ui/locales/tl.js +++ b/src/firefox/src/ui/locales/tl.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Naputol ang pag-stream ng tugon; sinusubukang muli ang Ask turn na ito nang walang streaming.', 'sp.providers.no_setup_group': 'Walang setup', diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index 01be5822f..76d608452 100644 --- a/src/firefox/src/ui/locales/tr.js +++ b/src/firefox/src/ui/locales/tr.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Yanıt akışı kesildi; bu Ask turu akış olmadan yeniden deneniyor.', 'sp.providers.no_setup_group': 'Kurulum gerektirmez', diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index 0c874ca00..708c77b7c 100644 --- a/src/firefox/src/ui/locales/uk.js +++ b/src/firefox/src/ui/locales/uk.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': 'Потік відповіді перервано; цей запит Ask повторюється без потокової передачі.', 'sp.providers.no_setup_group': 'Без налаштування', diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index 3cee7c5ff..505a5cb4e 100644 --- a/src/firefox/src/ui/locales/vi.js +++ b/src/firefox/src/ui/locales/vi.js @@ -2,6 +2,12 @@ import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', 'sp.streaming.fallback': 'Luồng phản hồi bị gián đoạn; đang thử lại lượt Ask này mà không phát trực tuyến.', 'sp.providers.no_setup_group': "Không cần thiết lập", 'sp.providers.no_setup': "Không cần thiết lập", diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index 1996deb76..d5d5edb5b 100644 --- a/src/firefox/src/ui/locales/zh.js +++ b/src/firefox/src/ui/locales/zh.js @@ -4,6 +4,12 @@ import chromeWebStoreLocale from './chrome-web-store.mjs'; import { getApocalypseModeCopy } from './apocalypse-copy.mjs'; export default { + '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', + 'sp.message_info.finish': 'Stop reason: {reason}', + 'sp.message_info.hint': 'Click to show message info', ...chromeWebStoreLocale, 'sp.streaming.fallback': '响应流已中断;正在以非流式方式重试本次 Ask。', 'sp.providers.no_setup_group': '无需配置', diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 6c5ad8475..47bfe0098 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -26,6 +26,7 @@ import { claimRunError } from './run-error-dedupe.js'; import { RUN_CAPTURE_START_ERROR_PREFIX } from '../run-capture.js'; import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; import { formatErrorMessage } from '../error-format.js'; +import { buildMessageInfoPills } from '../message-info.js'; import { escapeHtml } from './utils.js'; import { isBackgroundConnectionError, @@ -1806,7 +1807,7 @@ function extractChatHistoryMessages(root = messagesEl) { text: normalizeHistoryText(historyTextFromElement(textEl, { markdown: format === 'markdown' })), format, index, - createdAt: Date.now(), + createdAt: messageCreatedAt(msgEl), ...(attachments.length ? { attachments } : {}), }; }).filter((message) => message.text || message.attachments?.length); @@ -3991,6 +3992,7 @@ async function init() { if (changes.verboseMode) { verboseMode = changes.verboseMode.newValue; if (verboseBtn) verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); } if (changes.alwaysAllowApiMutations) { alwaysAllowApiMutations = changes.alwaysAllowApiMutations.newValue === true; @@ -4043,6 +4045,7 @@ if (verboseBtn) { // Normal click → toggle verbose mode verboseMode = !verboseMode; verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); await browser.storage.local.set({ verboseMode }).catch(() => {}); }); } @@ -5980,6 +5983,7 @@ function renderAgentErrorUpdate(data, tabId = currentTabId, requestId = '', opti function rebindRestoredMessageControls() { restoreStagedScreenshotAttachments(); rebindCopyButtons(); + rebindMessageInfoToggles(); rebindCompactStepDetailsToggles(); rebindScreenshotSaveButtons(); rebindRetryButtons(); @@ -7259,6 +7263,7 @@ async function parseSlashCommands(text, tabId = currentTabId, options = {}) { if (command.value === '/verbose') { verboseMode = !verboseMode; if (verboseBtn) verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); await browser.storage.local.set({ verboseMode }).catch(() => {}); if (currentTabId !== tabId) return ''; showComposerToast(systemHtml(verboseMode @@ -8422,6 +8427,10 @@ function handleAgentUpdateMessage(msg) { reportTrailingRunCaptureError({ kind: data?.kind }, new Error(data?.message || 'unknown error'), eventTabId); break; + case 'message_info': + applyMessageCompletion(eventAssistantEl || currentAssistantEl, data); + break; + case 'error': hideActivity(); if (currentAssistantEl) markLastStepFailed(); @@ -8469,6 +8478,7 @@ function handleAgentUpdateMessage(msg) { break; case 'run_complete': + setMessageCreatedAt(eventAssistantEl || currentAssistantEl, data?.endedAt, { replace: true }); if (currentAssistantEl) finalizeSteps(currentAssistantEl); reconcileRunMessageAttachmentState( eventTabId, @@ -10106,6 +10116,125 @@ function messageAttachmentMetadata(msgEl) { }, item.dataset.deliveryState)); } +function messageCreatedAt(msgEl) { + const value = Number(msgEl?.dataset?.messageCreatedAt); + return Number.isFinite(value) && value > 0 ? value : Date.now(); +} + +function setMessageCreatedAt(msgEl, value = Date.now(), { replace = false } = {}) { + if (!msgEl) return null; + const existing = Number(msgEl.dataset.messageCreatedAt); + if (!replace && Number.isFinite(existing) && existing > 0) return existing; + const candidate = Number(value); + const next = Number.isFinite(candidate) && candidate > 0 + ? candidate + : (Number.isFinite(existing) && existing > 0 ? existing : Date.now()); + msgEl.dataset.messageCreatedAt = String(Math.round(next)); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + return next; +} + +function messageCompletionFromElement(msgEl) { + return { + inputTokens: Number(msgEl?.dataset?.messageInputTokens) || 0, + outputTokens: Number(msgEl?.dataset?.messageOutputTokens) || 0, + totalTokens: Number(msgEl?.dataset?.messageTotalTokens) || 0, + durationMs: Number(msgEl?.dataset?.messageDurationMs) || 0, + finishReason: String(msgEl?.dataset?.messageFinishReason || ''), + }; +} + +function renderMessageInfo(msgEl) { + if (!msgEl) return; + let row = msgEl.querySelector(':scope > .message-info'); + if (!row) { + row = document.createElement('div'); + row.className = 'message-info'; + row.setAttribute('role', 'status'); + msgEl.appendChild(row); + } + const pills = buildMessageInfoPills({ + createdAt: messageCreatedAt(msgEl), + completion: messageCompletionFromElement(msgEl), + verbose: verboseMode, + locale: getLocale(), + }); + row.replaceChildren(...pills.map((pill) => { + const item = document.createElement('span'); + item.className = pill.kind === 'sent' + ? 'message-info-item message-info-sent' + : `message-info-item message-info-pill message-info-${pill.kind}`; + item.textContent = t(pill.key, pill.params); + return item; + })); + row.hidden = !msgEl.classList.contains('message-info-open') || pills.length === 0; +} + +function messageInfoClickIsInteractive(target) { + return !!target?.closest?.( + 'a, button, input, textarea, select, summary, [role="button"], [contenteditable="true"]', + ); +} + +function toggleMessageInfo(msgEl) { + const open = msgEl.classList.toggle('message-info-open'); + msgEl.setAttribute('aria-expanded', String(open)); + renderMessageInfo(msgEl); + schedulePersist(); +} + +function bindMessageInfoToggle(msgEl) { + if (!msgEl?.matches?.('.message.user, .message.assistant')) return; + setMessageCreatedAt(msgEl); + msgEl.tabIndex = 0; + msgEl.title = t('sp.message_info.hint'); + msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + if (msgEl.__wbMessageInfoBound) return; + msgEl.__wbMessageInfoBound = true; + msgEl.addEventListener('click', (event) => { + if (messageInfoClickIsInteractive(event.target)) return; + toggleMessageInfo(msgEl); + }); + msgEl.addEventListener('keydown', (event) => { + if (event.target !== msgEl || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + toggleMessageInfo(msgEl); + }); +} + +function rebindMessageInfoToggles() { + messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant') + .forEach(bindMessageInfoToggle); +} + +function applyMessageCompletion(msgEl, completion = {}) { + if (!msgEl) return; + const values = { + messageInputTokens: completion.inputTokens, + messageOutputTokens: completion.outputTokens, + messageTotalTokens: completion.totalTokens, + messageDurationMs: completion.durationMs, + }; + for (const [key, value] of Object.entries(values)) { + const number = Number(value); + msgEl.dataset[key] = String(Number.isFinite(number) && number >= 0 ? Math.round(number) : 0); + } + msgEl.dataset.messageFinishReason = String(completion.finishReason || '') + .replace(/[\u0000-\u001f\u007f]/g, '') + .trim() + .slice(0, 80); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + schedulePersist(); +} + +function refreshOpenMessageInfoRows() { + messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { + msgEl.title = t('sp.message_info.hint'); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + }); +} + function addMessage(role, content, options = {}) { const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; @@ -10156,6 +10285,10 @@ function addMessage(role, content, options = {}) { } else { messagesEl.appendChild(msgEl); } + if (role === 'user' || role === 'assistant') { + setMessageCreatedAt(msgEl, options.createdAt); + bindMessageInfoToggle(msgEl); + } if (role === 'error' && options.retryPayload && !textEl.classList.contains('cost-allowance-error')) { @@ -12018,6 +12151,7 @@ document.addEventListener('wb-locale-changed', () => { if (slashCommandMatches.length) renderSlashCommandAutocomplete(); renderQueuedComposerMessages(); syncSelectionScopeUi(); + refreshOpenMessageInfoRows(); void loadProviders(); }); diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index 3ccffe6dc..88d25d52d 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -1256,6 +1256,17 @@ body { width: 95%; } +.message.user, +.message.assistant { + cursor: pointer; +} + +.message.user:focus-visible, +.message.assistant:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + .message.assistant.assistant-awaiting-content { display: none; } @@ -1327,6 +1338,40 @@ body { border-bottom-left-radius: 4px; } +.message-info { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 5px; + padding: 0 4px; + color: var(--text-secondary); + font-size: 10px; + font-variant-numeric: tabular-nums; + line-height: 1.35; +} + +.message.user .message-info { + align-self: flex-end; + justify-content: flex-end; +} + +.message-info[hidden] { + display: none; +} + +.message-info-pill { + padding: 3px 8px; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--bg-secondary); + color: var(--text-primary); +} + +.message-info-sent { + opacity: 0.9; +} + .message.system .message-content { background: var(--accent-dim); border: 1px solid rgba(108, 99, 255, 0.2); diff --git a/test/run.js b/test/run.js index c9cd8978e..781265df1 100644 --- a/test/run.js +++ b/test/run.js @@ -80970,4 +80970,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', + }); + + 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`); + } +}); + +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.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`); + } +}); + +test('sidepanels reveal persisted message info while verbose gates completion details', () => { + const messageInfoSources = []; + for (const [label, prefix] of [ + ['chrome', 'src/chrome'], + ['firefox', 'src/firefox'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); + const agent = fs.readFileSync(path.join(ROOT, prefix, 'src/agent/agent.js'), 'utf8'); + const css = fs.readFileSync(path.join(ROOT, prefix, 'styles/sidepanel.css'), 'utf8'); + const locale = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/locales/en.js'), 'utf8'); + const messageInfo = fs.readFileSync(path.join(ROOT, prefix, 'src/message-info.js'), 'utf8'); + messageInfoSources.push(messageInfo); + + assert.match(agent, /import \{ aggregateMessageCompletion \} from '\.\.\/message-info\.js';/, `${label}: agent should use the message-info seam`); + assert.match( + agent, + /aggregateMessageCompletion\([\s\S]*?onUpdate\('message_info', messageCompletion\)/, + `${label}: each main model call should publish aggregate completion metadata`, + ); + assert.match(panel, /import \{ buildMessageInfoPills \} from '\.\.\/message-info\.js';/, `${label}: sidepanel should adapt message info to the DOM`); + assert.match( + panel, + /function bindMessageInfoToggle\([\s\S]*?addEventListener\('click'[\s\S]*?toggleMessageInfo\(/, + `${label}: clicking a chat message should toggle its info row`, + ); + assert.match(panel, /case 'message_info':[\s\S]*?applyMessageCompletion\(/, `${label}: live completion metadata should reach the active message`); + assert.match(panel, /case 'run_complete':[\s\S]*?setMessageCreatedAt\([\s\S]*?data\?\.endedAt/, `${label}: assistant sent time should use the terminal timestamp`); + assert.match(panel, /function rebindRestoredMessageControls\(\)[\s\S]*?rebindMessageInfoToggles\(\)/, `${label}: restored messages should regain click behavior`); + assert.match(panel, /createdAt: messageCreatedAt\(msgEl\)/, `${label}: durable history should preserve each message timestamp`); + assert.match(css, /\.message-info \{[\s\S]*?\.message-info-pill \{/, `${label}: info rows and verbose pills should be styled`); + 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`); + } + } + assert.equal(messageInfoSources[1], messageInfoSources[0], 'message-info behavior should stay byte-identical across browsers'); +}); + await run(); From 0494b09f7656cea9e726ce85aa6c7aa387a6c841 Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sat, 15 Aug 2026 13:29:10 +0800 Subject: [PATCH 2/5] fix: avoid misleading message metadata --- src/chrome/src/message-info.js | 23 +++++++++++------------ src/chrome/src/ui/sidepanel.js | 14 +++++++------- src/firefox/src/message-info.js | 23 +++++++++++------------ src/firefox/src/ui/sidepanel.js | 14 +++++++------- test/run.js | 8 ++++++++ 5 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/chrome/src/message-info.js b/src/chrome/src/message-info.js index ef7f31e86..7df244172 100644 --- a/src/chrome/src/message-info.js +++ b/src/chrome/src/message-info.js @@ -17,7 +17,7 @@ function formatSentTime(createdAt, locale) { } } -function finiteCount(...values) { +function firstPositiveInteger(...values) { for (const value of values) { const number = Number(value); if (Number.isFinite(number) && number > 0) return Math.floor(number); @@ -32,7 +32,6 @@ function finishReason(result) { ?? raw?.choices?.[0]?.finish_reason ?? raw?.stop_reason ?? raw?.stopReason - ?? raw?.status ?? '', ).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); } @@ -50,25 +49,25 @@ function formatNumber(value, locale, maximumFractionDigits = 0) { export function aggregateMessageCompletion(current, result, durationMs) { const previous = current || {}; const usage = result?.usage || {}; - const inputTokens = finiteCount( + const inputTokens = firstPositiveInteger( usage.prompt_tokens, usage.input_tokens, usage.promptTokens, usage.inputTokens, ); - const outputTokens = finiteCount( + const outputTokens = firstPositiveInteger( usage.completion_tokens, usage.output_tokens, usage.completionTokens, usage.outputTokens, ); - const reportedTotal = finiteCount(usage.total_tokens, usage.totalTokens); + const reportedTotal = firstPositiveInteger(usage.total_tokens, usage.totalTokens); const elapsed = Number(durationMs); return { - inputTokens: finiteCount(previous.inputTokens) + inputTokens, - outputTokens: finiteCount(previous.outputTokens) + outputTokens, - totalTokens: finiteCount(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), - durationMs: finiteCount(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), + inputTokens: firstPositiveInteger(previous.inputTokens) + inputTokens, + outputTokens: firstPositiveInteger(previous.outputTokens) + outputTokens, + totalTokens: firstPositiveInteger(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), + durationMs: firstPositiveInteger(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), finishReason: finishReason(result) || String(previous.finishReason || '').slice(0, 80), }; } @@ -82,9 +81,9 @@ export function buildMessageInfoPills({ createdAt, completion = {}, verbose = fa params: { time }, }]; if (!verbose) return pills; - const outputTokens = finiteCount(completion.outputTokens); - const displayedTokens = outputTokens || finiteCount(completion.totalTokens); - const durationMs = finiteCount(completion.durationMs); + const outputTokens = firstPositiveInteger(completion.outputTokens); + const displayedTokens = outputTokens || firstPositiveInteger(completion.totalTokens); + const durationMs = firstPositiveInteger(completion.durationMs); if (outputTokens && durationMs) { pills.push({ kind: 'speed', diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index c26edb100..4b7c617d9 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10434,17 +10434,16 @@ function messageAttachmentMetadata(msgEl) { function messageCreatedAt(msgEl) { const value = Number(msgEl?.dataset?.messageCreatedAt); - return Number.isFinite(value) && value > 0 ? value : Date.now(); + return Number.isFinite(value) && value > 0 ? value : undefined; } -function setMessageCreatedAt(msgEl, value = Date.now(), { replace = false } = {}) { +function setMessageCreatedAt(msgEl, value, { replace = false } = {}) { if (!msgEl) return null; const existing = Number(msgEl.dataset.messageCreatedAt); if (!replace && Number.isFinite(existing) && existing > 0) return existing; const candidate = Number(value); - const next = Number.isFinite(candidate) && candidate > 0 - ? candidate - : (Number.isFinite(existing) && existing > 0 ? existing : Date.now()); + const next = Number.isFinite(candidate) && candidate > 0 ? candidate : existing; + if (!Number.isFinite(next) || next <= 0) return null; msgEl.dataset.messageCreatedAt = String(Math.round(next)); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); return next; @@ -10501,7 +10500,7 @@ function toggleMessageInfo(msgEl) { function bindMessageInfoToggle(msgEl) { if (!msgEl?.matches?.('.message.user, .message.assistant')) return; - setMessageCreatedAt(msgEl); + if (!messageCreatedAt(msgEl)) return; msgEl.tabIndex = 0; msgEl.title = t('sp.message_info.hint'); msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); @@ -10546,6 +10545,7 @@ function applyMessageCompletion(msgEl, completion = {}) { function refreshOpenMessageInfoRows() { messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { + if (!messageCreatedAt(msgEl)) return; msgEl.title = t('sp.message_info.hint'); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); }); @@ -10602,7 +10602,7 @@ function addMessage(role, content, options = {}) { messagesEl.appendChild(msgEl); } if (role === 'user' || role === 'assistant') { - setMessageCreatedAt(msgEl, options.createdAt); + setMessageCreatedAt(msgEl, options.createdAt ?? Date.now()); bindMessageInfoToggle(msgEl); } diff --git a/src/firefox/src/message-info.js b/src/firefox/src/message-info.js index ef7f31e86..7df244172 100644 --- a/src/firefox/src/message-info.js +++ b/src/firefox/src/message-info.js @@ -17,7 +17,7 @@ function formatSentTime(createdAt, locale) { } } -function finiteCount(...values) { +function firstPositiveInteger(...values) { for (const value of values) { const number = Number(value); if (Number.isFinite(number) && number > 0) return Math.floor(number); @@ -32,7 +32,6 @@ function finishReason(result) { ?? raw?.choices?.[0]?.finish_reason ?? raw?.stop_reason ?? raw?.stopReason - ?? raw?.status ?? '', ).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 80); } @@ -50,25 +49,25 @@ function formatNumber(value, locale, maximumFractionDigits = 0) { export function aggregateMessageCompletion(current, result, durationMs) { const previous = current || {}; const usage = result?.usage || {}; - const inputTokens = finiteCount( + const inputTokens = firstPositiveInteger( usage.prompt_tokens, usage.input_tokens, usage.promptTokens, usage.inputTokens, ); - const outputTokens = finiteCount( + const outputTokens = firstPositiveInteger( usage.completion_tokens, usage.output_tokens, usage.completionTokens, usage.outputTokens, ); - const reportedTotal = finiteCount(usage.total_tokens, usage.totalTokens); + const reportedTotal = firstPositiveInteger(usage.total_tokens, usage.totalTokens); const elapsed = Number(durationMs); return { - inputTokens: finiteCount(previous.inputTokens) + inputTokens, - outputTokens: finiteCount(previous.outputTokens) + outputTokens, - totalTokens: finiteCount(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), - durationMs: finiteCount(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), + inputTokens: firstPositiveInteger(previous.inputTokens) + inputTokens, + outputTokens: firstPositiveInteger(previous.outputTokens) + outputTokens, + totalTokens: firstPositiveInteger(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), + durationMs: firstPositiveInteger(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), finishReason: finishReason(result) || String(previous.finishReason || '').slice(0, 80), }; } @@ -82,9 +81,9 @@ export function buildMessageInfoPills({ createdAt, completion = {}, verbose = fa params: { time }, }]; if (!verbose) return pills; - const outputTokens = finiteCount(completion.outputTokens); - const displayedTokens = outputTokens || finiteCount(completion.totalTokens); - const durationMs = finiteCount(completion.durationMs); + const outputTokens = firstPositiveInteger(completion.outputTokens); + const displayedTokens = outputTokens || firstPositiveInteger(completion.totalTokens); + const durationMs = firstPositiveInteger(completion.durationMs); if (outputTokens && durationMs) { pills.push({ kind: 'speed', diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 47bfe0098..7d84fcfc1 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -10118,17 +10118,16 @@ function messageAttachmentMetadata(msgEl) { function messageCreatedAt(msgEl) { const value = Number(msgEl?.dataset?.messageCreatedAt); - return Number.isFinite(value) && value > 0 ? value : Date.now(); + return Number.isFinite(value) && value > 0 ? value : undefined; } -function setMessageCreatedAt(msgEl, value = Date.now(), { replace = false } = {}) { +function setMessageCreatedAt(msgEl, value, { replace = false } = {}) { if (!msgEl) return null; const existing = Number(msgEl.dataset.messageCreatedAt); if (!replace && Number.isFinite(existing) && existing > 0) return existing; const candidate = Number(value); - const next = Number.isFinite(candidate) && candidate > 0 - ? candidate - : (Number.isFinite(existing) && existing > 0 ? existing : Date.now()); + const next = Number.isFinite(candidate) && candidate > 0 ? candidate : existing; + if (!Number.isFinite(next) || next <= 0) return null; msgEl.dataset.messageCreatedAt = String(Math.round(next)); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); return next; @@ -10185,7 +10184,7 @@ function toggleMessageInfo(msgEl) { function bindMessageInfoToggle(msgEl) { if (!msgEl?.matches?.('.message.user, .message.assistant')) return; - setMessageCreatedAt(msgEl); + if (!messageCreatedAt(msgEl)) return; msgEl.tabIndex = 0; msgEl.title = t('sp.message_info.hint'); msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); @@ -10230,6 +10229,7 @@ function applyMessageCompletion(msgEl, completion = {}) { function refreshOpenMessageInfoRows() { messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { + if (!messageCreatedAt(msgEl)) return; msgEl.title = t('sp.message_info.hint'); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); }); @@ -10286,7 +10286,7 @@ function addMessage(role, content, options = {}) { messagesEl.appendChild(msgEl); } if (role === 'user' || role === 'assistant') { - setMessageCreatedAt(msgEl, options.createdAt); + setMessageCreatedAt(msgEl, options.createdAt ?? Date.now()); bindMessageInfoToggle(msgEl); } diff --git a/test/run.js b/test/run.js index 781265df1..1f0df6956 100644 --- a/test/run.js +++ b/test/run.js @@ -81026,6 +81026,9 @@ test('message info aggregates model calls into verbose completion pills', async 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.deepEqual(buildMessageInfoPills({ createdAt: Date.parse('2024-12-12T12:44:00Z'), completion, @@ -81070,6 +81073,11 @@ test('sidepanels reveal persisted message info while verbose gates completion de assert.match(panel, /case 'run_complete':[\s\S]*?setMessageCreatedAt\([\s\S]*?data\?\.endedAt/, `${label}: assistant sent time should use the terminal timestamp`); assert.match(panel, /function rebindRestoredMessageControls\(\)[\s\S]*?rebindMessageInfoToggles\(\)/, `${label}: restored messages should regain click behavior`); assert.match(panel, /createdAt: messageCreatedAt\(msgEl\)/, `${label}: durable history should preserve each message timestamp`); + const createdAtReader = panel.match(/function messageCreatedAt\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; + assert.doesNotMatch(createdAtReader, /Date\.now\(\)/, `${label}: unknown legacy timestamps must remain unknown`); + const bindToggle = panel.match(/function bindMessageInfoToggle\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; + assert.doesNotMatch(bindToggle, /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`); 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`); From 5b116dcdca4fc1aba804a09e8975aa23837222ad Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sun, 16 Aug 2026 13:24:59 +0800 Subject: [PATCH 3/5] fix(message-info): pass endedAt through synthesized run_complete on restore Terminal restore in applyActiveRunState synthesized a run_complete event without data.endedAt, so restored assistant messages fell back to the request-start timestamp instead of the actual completion time. Mirror the same fix in both Chrome and Firefox builds. --- src/chrome/src/ui/sidepanel.js | 1 + src/firefox/src/ui/sidepanel.js | 1 + 2 files changed, 2 insertions(+) diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 4b7c617d9..f7e483b11 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -4588,6 +4588,7 @@ async function applyActiveRunState(numericTabId, state) { finalContent: runUi.finalContent, submittedTurnDurable: state?.submittedTurnDurable === true, attachmentDeliveryState: runUi.attachmentDeliveryState || '', + endedAt: runUi.endedAt, }, }); } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 7d84fcfc1..5f21dedc5 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -4434,6 +4434,7 @@ async function applyActiveRunState(numericTabId, state) { finalContent: runUi.finalContent, submittedTurnDurable: state?.submittedTurnDurable === true, attachmentDeliveryState: runUi.attachmentDeliveryState || '', + endedAt: runUi.endedAt, }, }); } From aafa249d98ecd46bcecde92a591f725fdd0f8fca Mon Sep 17 00:00:00 2001 From: alectimison-maker Date: Sun, 16 Aug 2026 15:37:30 +0800 Subject: [PATCH 4/5] fix(message-info): propagate streamed finish reasons and expose a semantic info toggle Address the remaining Copilot review comments on PR #2802: - Stream collectors now forward the terminal finish_reason/stop_reason observed by providers (OpenAI Chat Completions, Anthropic, llama.cpp, Azure OpenAI, Bedrock) through the done chunk into aggregate results, so the verbose stop-reason pill appears in the primary Ask path. - Replace the focusable message div (tabIndex + aria-expanded on a bare div) with a real toggle button carrying aria-controls/aria-expanded and a stable row id, while keeping bubble-click delegation. - Add a behavioral DOM test that exercises toggle, persist/restore, and terminal replay for both builds. --- src/chrome/src/agent/agent.js | 6 + src/chrome/src/providers/anthropic.js | 4 +- src/chrome/src/providers/aws-bedrock.js | 7 +- src/chrome/src/providers/azure-openai.js | 4 +- src/chrome/src/providers/llamacpp.js | 6 +- src/chrome/src/providers/openai.js | 15 +- src/chrome/src/ui/sidepanel.js | 62 +++++-- src/chrome/styles/sidepanel.css | 36 ++++ src/firefox/src/agent/agent.js | 6 + src/firefox/src/providers/anthropic.js | 4 +- src/firefox/src/providers/aws-bedrock.js | 7 +- src/firefox/src/providers/azure-openai.js | 4 +- src/firefox/src/providers/llamacpp.js | 6 +- src/firefox/src/providers/openai.js | 15 +- src/firefox/src/ui/sidepanel.js | 62 +++++-- src/firefox/styles/sidepanel.css | 36 ++++ test/run.js | 209 +++++++++++++++++++++- 17 files changed, 445 insertions(+), 44 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index fd89c3510..c44733a65 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1920,6 +1920,8 @@ export class Agent extends LoopDetector { let reasoningContent = ''; let usage = null; let responseItems = null; + let terminalFinishReason = ''; + let terminalRaw = null; let sawCompleted = false; let usageRecorded = false; const toolCalls = new Map(); @@ -1987,6 +1989,8 @@ export class Agent extends LoopDetector { } else if (chunk?.type === 'done') { if (Array.isArray(chunk.responseItems)) responseItems = chunk.responseItems; if (chunk.usage) usage = chunk.usage; + if (chunk.finishReason) terminalFinishReason = String(chunk.finishReason); + if (chunk.raw) terminalRaw = chunk.raw; sawCompleted = true; break; } @@ -2013,6 +2017,8 @@ export class Agent extends LoopDetector { toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null, usage, responseItems, + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + ...(terminalRaw ? { raw: terminalRaw } : {}), }; const after = await recordUsage(); if (after) result.costAllowanceMessage = after; diff --git a/src/chrome/src/providers/anthropic.js b/src/chrome/src/providers/anthropic.js index 6f478426f..d8c305e96 100644 --- a/src/chrome/src/providers/anthropic.js +++ b/src/chrome/src/providers/anthropic.js @@ -318,6 +318,7 @@ export class AnthropicProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let sawUsage = false; + let terminalStopReason = ''; const accumulatedUsage = {}; const updateUsage = (usage) => { if (!usage || typeof usage !== 'object') return; @@ -390,6 +391,7 @@ export class AnthropicProvider extends BaseLLMProvider { updateUsage(event.message?.usage); } else if (event.type === 'message_delta') { updateUsage(event.usage); + if (event.delta?.stop_reason) terminalStopReason = event.delta.stop_reason; } else if (event.type === 'content_block_delta') { if (event.delta?.type === 'text_delta') { yield { type: 'text', content: event.delta.text }; @@ -409,7 +411,7 @@ export class AnthropicProvider extends BaseLLMProvider { } else if (event.type === 'message_stop') { const usage = usageChunk(); if (usage) yield { type: 'usage', usage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalStopReason ? { finishReason: terminalStopReason } : {}) }; return; } } diff --git a/src/chrome/src/providers/aws-bedrock.js b/src/chrome/src/providers/aws-bedrock.js index 49725202f..2ca268c22 100644 --- a/src/chrome/src/providers/aws-bedrock.js +++ b/src/chrome/src/providers/aws-bedrock.js @@ -314,7 +314,12 @@ export class AwsBedrockProvider extends BaseLLMProvider { if (res.content) yield { type: 'text', content: res.content }; if (res.toolCalls) yield { type: 'tool_call', content: res.toolCalls }; if (res.usage) yield { type: 'usage', usage: res.usage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(res.raw?.stopReason ? { finishReason: res.raw.stopReason } : {}), + ...(res.raw ? { raw: res.raw } : {}), + }; } } diff --git a/src/chrome/src/providers/azure-openai.js b/src/chrome/src/providers/azure-openai.js index 045c31896..4b2b011c7 100644 --- a/src/chrome/src/providers/azure-openai.js +++ b/src/chrome/src/providers/azure-openai.js @@ -158,6 +158,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; try { @@ -179,7 +180,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } let json; @@ -205,6 +206,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { `${this.name} stream was blocked by the Azure content filter.`, ); } + if (choice?.finish_reason) terminalFinishReason = choice.finish_reason; const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { diff --git a/src/chrome/src/providers/llamacpp.js b/src/chrome/src/providers/llamacpp.js index 8fd285ebd..a97c219f4 100644 --- a/src/chrome/src/providers/llamacpp.js +++ b/src/chrome/src/providers/llamacpp.js @@ -124,6 +124,7 @@ export class LlamaCppProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; @@ -148,7 +149,7 @@ export class LlamaCppProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } let json; @@ -175,6 +176,7 @@ export class LlamaCppProvider extends BaseLLMProvider { if (choice?.finish_reason === 'content_filter') { throw this._askStreamTerminalError('llama.cpp stream was blocked by the provider content filter.'); } + if (choice?.finish_reason) terminalFinishReason = choice.finish_reason; const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { @@ -192,6 +194,6 @@ export class LlamaCppProvider extends BaseLLMProvider { if (this._supportsInteractiveAskStreaming()) { throw this._askStreamTransportError('llama.cpp stream ended before the [DONE] sentinel.'); } - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; } } diff --git a/src/chrome/src/providers/openai.js b/src/chrome/src/providers/openai.js index 56dab64e4..4dc490a5d 100644 --- a/src/chrome/src/providers/openai.js +++ b/src/chrome/src/providers/openai.js @@ -79,6 +79,11 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { get model() { if (this.config.model) return this.config.model; if (this.config.requiresModel) throw new Error(`${this.config.label || this.name} model is required.`); + // Local servers (Ollama, LM Studio, vLLM, …) must never receive a model + // id the user never configured: most 404 on unknown ids and none serves + // a model named after OpenAI's default. Omit the field entirely so the + // server applies its own default. Mirrors LlamaCppProvider. + if (this.config.category === 'local') return null; return String(this.config.providerName || '').toLowerCase() === 'openai' && this._isOfficialOpenAIBaseUrl() ? 'gpt-5.6-terra' @@ -376,10 +381,10 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { */ _buildChatCompletionsBody(messages, options = {}, stream = false) { let body = { - model: this.model, messages: this._chatMessages(messages, options), stream, }; + if (this.model) body.model = this.model; this._addTemperature(body, options); this._addMaxTokens(body, options); if (this._shouldSendTools(messages, options)) { @@ -499,7 +504,6 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { _responsesBody(messages, options, stream) { let body = { - model: this.model, input: this._responsesInput(messages), stream, store: false, @@ -514,6 +518,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { if (body.reasoning.effort === 'auto' || body.reasoning.effort === 'off') { body.reasoning.effort = body.reasoning.effort === 'off' ? 'none' : 'medium'; } + if (this.model) body.model = this.model; if (this._shouldSendTools(messages, options)) { body.tools = this._responsesTools(options.tools); @@ -949,6 +954,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { let buffer = ''; let finalUsage = null; let sawTerminalFinish = false; + let terminalFinishReason = ''; while (true) { let chunk; @@ -973,7 +979,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } let json; @@ -1012,6 +1018,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finishReason != null) { sawTerminalFinish = true; + terminalFinishReason = finishReason; } const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; @@ -1028,7 +1035,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finalUsage) yield { type: 'usage', usage: finalUsage }; if (sawTerminalFinish) { - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } if (this._supportsInteractiveAskStreaming()) { diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index f7e483b11..6575dbd97 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10460,12 +10460,47 @@ function messageCompletionFromElement(msgEl) { }; } +let messageInfoRowUid = 0; + +function messageInfoRowId(msgEl) { + const existingRow = msgEl.querySelector(':scope > .message-info'); + if (existingRow?.id) { + msgEl.__wbMessageInfoRowId = existingRow.id; + return existingRow.id; + } + if (!msgEl.__wbMessageInfoRowId) { + messageInfoRowUid += 1; + msgEl.__wbMessageInfoRowId = `message-info-${messageInfoRowUid}`; + } + return msgEl.__wbMessageInfoRowId; +} + +function messageInfoToggleButton(msgEl) { + let toggle = msgEl.querySelector(':scope > .message-info-toggle'); + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'message-info-toggle'; + toggle.textContent = 'i'; + msgEl.appendChild(toggle); + } + return toggle; +} + +function syncMessageInfoToggleState(msgEl) { + const open = msgEl.classList.contains('message-info-open'); + const toggle = msgEl.querySelector(':scope > .message-info-toggle'); + if (toggle) toggle.setAttribute('aria-expanded', String(open)); + return open; +} + function renderMessageInfo(msgEl) { if (!msgEl) return; let row = msgEl.querySelector(':scope > .message-info'); if (!row) { row = document.createElement('div'); row.className = 'message-info'; + row.id = messageInfoRowId(msgEl); row.setAttribute('role', 'status'); msgEl.appendChild(row); } @@ -10494,7 +10529,7 @@ function messageInfoClickIsInteractive(target) { function toggleMessageInfo(msgEl) { const open = msgEl.classList.toggle('message-info-open'); - msgEl.setAttribute('aria-expanded', String(open)); + syncMessageInfoToggleState(msgEl); renderMessageInfo(msgEl); schedulePersist(); } @@ -10502,19 +10537,20 @@ function toggleMessageInfo(msgEl) { function bindMessageInfoToggle(msgEl) { if (!msgEl?.matches?.('.message.user, .message.assistant')) return; if (!messageCreatedAt(msgEl)) return; - msgEl.tabIndex = 0; - msgEl.title = t('sp.message_info.hint'); - msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); - if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); if (msgEl.__wbMessageInfoBound) return; msgEl.__wbMessageInfoBound = true; - msgEl.addEventListener('click', (event) => { - if (messageInfoClickIsInteractive(event.target)) return; + const toggle = messageInfoToggleButton(msgEl); + toggle.setAttribute('aria-controls', messageInfoRowId(msgEl)); + toggle.setAttribute('aria-label', t('sp.message_info.hint')); + toggle.title = t('sp.message_info.hint'); + syncMessageInfoToggleState(msgEl); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + toggle.addEventListener('click', (event) => { + event.stopPropagation(); toggleMessageInfo(msgEl); }); - msgEl.addEventListener('keydown', (event) => { - if (event.target !== msgEl || (event.key !== 'Enter' && event.key !== ' ')) return; - event.preventDefault(); + msgEl.addEventListener('click', (event) => { + if (messageInfoClickIsInteractive(event.target)) return; toggleMessageInfo(msgEl); }); } @@ -10547,7 +10583,11 @@ function applyMessageCompletion(msgEl, completion = {}) { function refreshOpenMessageInfoRows() { messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { if (!messageCreatedAt(msgEl)) return; - msgEl.title = t('sp.message_info.hint'); + const toggle = msgEl.querySelector(':scope > .message-info-toggle'); + if (toggle) { + toggle.setAttribute('aria-label', t('sp.message_info.hint')); + toggle.title = t('sp.message_info.hint'); + } if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); }); } diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index 2455b599c..901a49f10 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -1482,6 +1482,42 @@ body { display: none; } +.message-info-toggle { + align-self: flex-start; + margin-top: 5px; + margin-left: 4px; + width: 16px; + height: 16px; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.message:hover .message-info-toggle, +.message-info-toggle:focus-visible { + opacity: 1; +} + +.message-info-toggle:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.message.user .message-info-toggle { + align-self: flex-end; + margin-left: 0; + margin-right: 4px; +} + .message-info-pill { padding: 3px 8px; border: 1px solid var(--border); diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 18dca8897..018889133 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1872,6 +1872,8 @@ export class Agent extends LoopDetector { let reasoningContent = ''; let usage = null; let responseItems = null; + let terminalFinishReason = ''; + let terminalRaw = null; let sawCompleted = false; let usageRecorded = false; const toolCalls = new Map(); @@ -1939,6 +1941,8 @@ export class Agent extends LoopDetector { } else if (chunk?.type === 'done') { if (Array.isArray(chunk.responseItems)) responseItems = chunk.responseItems; if (chunk.usage) usage = chunk.usage; + if (chunk.finishReason) terminalFinishReason = String(chunk.finishReason); + if (chunk.raw) terminalRaw = chunk.raw; sawCompleted = true; break; } @@ -1965,6 +1969,8 @@ export class Agent extends LoopDetector { toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null, usage, responseItems, + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + ...(terminalRaw ? { raw: terminalRaw } : {}), }; const after = await recordUsage(); if (after) result.costAllowanceMessage = after; diff --git a/src/firefox/src/providers/anthropic.js b/src/firefox/src/providers/anthropic.js index 15b29da98..a57aebbf9 100644 --- a/src/firefox/src/providers/anthropic.js +++ b/src/firefox/src/providers/anthropic.js @@ -316,6 +316,7 @@ export class AnthropicProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let sawUsage = false; + let terminalStopReason = ''; const accumulatedUsage = {}; const updateUsage = (usage) => { if (!usage || typeof usage !== 'object') return; @@ -388,6 +389,7 @@ export class AnthropicProvider extends BaseLLMProvider { updateUsage(event.message?.usage); } else if (event.type === 'message_delta') { updateUsage(event.usage); + if (event.delta?.stop_reason) terminalStopReason = event.delta.stop_reason; } else if (event.type === 'content_block_delta') { if (event.delta?.type === 'text_delta') { yield { type: 'text', content: event.delta.text }; @@ -407,7 +409,7 @@ export class AnthropicProvider extends BaseLLMProvider { } else if (event.type === 'message_stop') { const usage = usageChunk(); if (usage) yield { type: 'usage', usage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalStopReason ? { finishReason: terminalStopReason } : {}) }; return; } } diff --git a/src/firefox/src/providers/aws-bedrock.js b/src/firefox/src/providers/aws-bedrock.js index 3cd594e72..70fcee587 100644 --- a/src/firefox/src/providers/aws-bedrock.js +++ b/src/firefox/src/providers/aws-bedrock.js @@ -294,7 +294,12 @@ export class AwsBedrockProvider extends BaseLLMProvider { if (res.content) yield { type: 'text', content: res.content }; if (res.toolCalls) yield { type: 'tool_call', content: res.toolCalls }; if (res.usage) yield { type: 'usage', usage: res.usage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(res.raw?.stopReason ? { finishReason: res.raw.stopReason } : {}), + ...(res.raw ? { raw: res.raw } : {}), + }; } } diff --git a/src/firefox/src/providers/azure-openai.js b/src/firefox/src/providers/azure-openai.js index 79d6708d2..9f94beb67 100644 --- a/src/firefox/src/providers/azure-openai.js +++ b/src/firefox/src/providers/azure-openai.js @@ -155,6 +155,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; try { @@ -176,7 +177,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } let json; @@ -202,6 +203,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { `${this.name} stream was blocked by the Azure content filter.`, ); } + if (choice?.finish_reason) terminalFinishReason = choice.finish_reason; const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { diff --git a/src/firefox/src/providers/llamacpp.js b/src/firefox/src/providers/llamacpp.js index 42a436ece..27d8db7f2 100644 --- a/src/firefox/src/providers/llamacpp.js +++ b/src/firefox/src/providers/llamacpp.js @@ -124,6 +124,7 @@ export class LlamaCppProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; @@ -148,7 +149,7 @@ export class LlamaCppProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } let json; @@ -175,6 +176,7 @@ export class LlamaCppProvider extends BaseLLMProvider { if (choice?.finish_reason === 'content_filter') { throw this._askStreamTerminalError('llama.cpp stream was blocked by the provider content filter.'); } + if (choice?.finish_reason) terminalFinishReason = choice.finish_reason; const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { @@ -192,6 +194,6 @@ export class LlamaCppProvider extends BaseLLMProvider { if (this._supportsInteractiveAskStreaming()) { throw this._askStreamTransportError('llama.cpp stream ended before the [DONE] sentinel.'); } - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; } } diff --git a/src/firefox/src/providers/openai.js b/src/firefox/src/providers/openai.js index 95cb8dca6..784c0e94e 100644 --- a/src/firefox/src/providers/openai.js +++ b/src/firefox/src/providers/openai.js @@ -79,6 +79,11 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { get model() { if (this.config.model) return this.config.model; if (this.config.requiresModel) throw new Error(`${this.config.label || this.name} model is required.`); + // Local servers (Ollama, LM Studio, vLLM, …) must never receive a model + // id the user never configured: most 404 on unknown ids and none serves + // a model named after OpenAI's default. Omit the field entirely so the + // server applies its own default. Mirrors LlamaCppProvider. + if (this.config.category === 'local') return null; return String(this.config.providerName || '').toLowerCase() === 'openai' && this._isOfficialOpenAIBaseUrl() ? 'gpt-5.6-terra' @@ -376,10 +381,10 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { */ _buildChatCompletionsBody(messages, options = {}, stream = false) { let body = { - model: this.model, messages: this._chatMessages(messages, options), stream, }; + if (this.model) body.model = this.model; this._addTemperature(body, options); this._addMaxTokens(body, options); if (this._shouldSendTools(messages, options)) { @@ -499,7 +504,6 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { _responsesBody(messages, options, stream) { let body = { - model: this.model, input: this._responsesInput(messages), stream, store: false, @@ -514,6 +518,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { if (body.reasoning.effort === 'auto' || body.reasoning.effort === 'off') { body.reasoning.effort = body.reasoning.effort === 'off' ? 'none' : 'medium'; } + if (this.model) body.model = this.model; if (this._shouldSendTools(messages, options)) { body.tools = this._responsesTools(options.tools); @@ -949,6 +954,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { let buffer = ''; let finalUsage = null; let sawTerminalFinish = false; + let terminalFinishReason = ''; while (true) { let chunk; @@ -973,7 +979,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } let json; @@ -1012,6 +1018,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finishReason != null) { sawTerminalFinish = true; + terminalFinishReason = finishReason; } const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; @@ -1028,7 +1035,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finalUsage) yield { type: 'usage', usage: finalUsage }; if (sawTerminalFinish) { - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}) }; return; } if (this._supportsInteractiveAskStreaming()) { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 5f21dedc5..c6cd66b11 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -10144,12 +10144,47 @@ function messageCompletionFromElement(msgEl) { }; } +let messageInfoRowUid = 0; + +function messageInfoRowId(msgEl) { + const existingRow = msgEl.querySelector(':scope > .message-info'); + if (existingRow?.id) { + msgEl.__wbMessageInfoRowId = existingRow.id; + return existingRow.id; + } + if (!msgEl.__wbMessageInfoRowId) { + messageInfoRowUid += 1; + msgEl.__wbMessageInfoRowId = `message-info-${messageInfoRowUid}`; + } + return msgEl.__wbMessageInfoRowId; +} + +function messageInfoToggleButton(msgEl) { + let toggle = msgEl.querySelector(':scope > .message-info-toggle'); + if (!toggle) { + toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.className = 'message-info-toggle'; + toggle.textContent = 'i'; + msgEl.appendChild(toggle); + } + return toggle; +} + +function syncMessageInfoToggleState(msgEl) { + const open = msgEl.classList.contains('message-info-open'); + const toggle = msgEl.querySelector(':scope > .message-info-toggle'); + if (toggle) toggle.setAttribute('aria-expanded', String(open)); + return open; +} + function renderMessageInfo(msgEl) { if (!msgEl) return; let row = msgEl.querySelector(':scope > .message-info'); if (!row) { row = document.createElement('div'); row.className = 'message-info'; + row.id = messageInfoRowId(msgEl); row.setAttribute('role', 'status'); msgEl.appendChild(row); } @@ -10178,7 +10213,7 @@ function messageInfoClickIsInteractive(target) { function toggleMessageInfo(msgEl) { const open = msgEl.classList.toggle('message-info-open'); - msgEl.setAttribute('aria-expanded', String(open)); + syncMessageInfoToggleState(msgEl); renderMessageInfo(msgEl); schedulePersist(); } @@ -10186,19 +10221,20 @@ function toggleMessageInfo(msgEl) { function bindMessageInfoToggle(msgEl) { if (!msgEl?.matches?.('.message.user, .message.assistant')) return; if (!messageCreatedAt(msgEl)) return; - msgEl.tabIndex = 0; - msgEl.title = t('sp.message_info.hint'); - msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); - if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); if (msgEl.__wbMessageInfoBound) return; msgEl.__wbMessageInfoBound = true; - msgEl.addEventListener('click', (event) => { - if (messageInfoClickIsInteractive(event.target)) return; + const toggle = messageInfoToggleButton(msgEl); + toggle.setAttribute('aria-controls', messageInfoRowId(msgEl)); + toggle.setAttribute('aria-label', t('sp.message_info.hint')); + toggle.title = t('sp.message_info.hint'); + syncMessageInfoToggleState(msgEl); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + toggle.addEventListener('click', (event) => { + event.stopPropagation(); toggleMessageInfo(msgEl); }); - msgEl.addEventListener('keydown', (event) => { - if (event.target !== msgEl || (event.key !== 'Enter' && event.key !== ' ')) return; - event.preventDefault(); + msgEl.addEventListener('click', (event) => { + if (messageInfoClickIsInteractive(event.target)) return; toggleMessageInfo(msgEl); }); } @@ -10231,7 +10267,11 @@ function applyMessageCompletion(msgEl, completion = {}) { function refreshOpenMessageInfoRows() { messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { if (!messageCreatedAt(msgEl)) return; - msgEl.title = t('sp.message_info.hint'); + const toggle = msgEl.querySelector(':scope > .message-info-toggle'); + if (toggle) { + toggle.setAttribute('aria-label', t('sp.message_info.hint')); + toggle.title = t('sp.message_info.hint'); + } if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); }); } diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index 88d25d52d..bca4413de 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -1360,6 +1360,42 @@ body { display: none; } +.message-info-toggle { + align-self: flex-start; + margin-top: 5px; + margin-left: 4px; + width: 16px; + height: 16px; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} + +.message:hover .message-info-toggle, +.message-info-toggle:focus-visible { + opacity: 1; +} + +.message-info-toggle:hover { + color: var(--text-primary); + border-color: var(--text-muted); +} + +.message.user .message-info-toggle { + align-self: flex-end; + margin-left: 0; + margin-right: 4px; +} + .message-info-pill { padding: 3px 8px; border: 1px solid var(--border); diff --git a/test/run.js b/test/run.js index 1f0df6956..5c92f9dbd 100644 --- a/test/run.js +++ b/test/run.js @@ -23707,7 +23707,7 @@ test('all locales cover English keys and preserve interpolation placeholders', a ); } } - } + } }); test('Apocalypse Mode copy is translated instead of inherited from English in every locale', async () => { @@ -45740,8 +45740,8 @@ test('Chat Completions streaming rejects premature EOF and accepts terminal comp } assert.deepEqual(complete, [ { type: 'text', content: 'done' }, - { type: 'done', content: '' }, - ], `${label}: terminal finish_reason should complete the stream`); + { type: 'done', content: '', finishReason: 'stop' }, + ], `${label}: terminal finish_reason should complete the stream with its reason`); } } finally { globalThis.fetch = originalFetch; @@ -81069,13 +81069,19 @@ test('sidepanels reveal persisted message info while verbose gates completion de /function bindMessageInfoToggle\([\s\S]*?addEventListener\('click'[\s\S]*?toggleMessageInfo\(/, `${label}: clicking a chat message should toggle its info row`, ); + assert.match(panel, /function messageInfoToggleButton\(msgEl\)[\s\S]*?createElement\('button'\)/, `${label}: message info should expose a real toggle button`); + assert.match(panel, /setAttribute\('aria-controls', messageInfoRowId\(msgEl\)\)/, `${label}: the toggle button should name the info row it controls`); + assert.match(panel, /syncMessageInfoToggleState\(msgEl\)[\s\S]*?setAttribute\('aria-expanded', String\(open\)\)/, `${label}: the toggle button should publish its expanded state`); + assert.match(panel, /function messageInfoRowId\(msgEl\)[\s\S]*?`message-info-\$\{messageInfoRowUid\}`/, `${label}: info rows should receive stable ids for aria-controls`); + const bindToggle = panel.match(/function bindMessageInfoToggle\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; + assert.doesNotMatch(bindToggle, /tabIndex = 0/, `${label}: the message bubble must not become a raw focus target`); + assert.doesNotMatch(bindToggle, /aria-expanded'/, `${label}: the bubble must not publish aria-expanded`); assert.match(panel, /case 'message_info':[\s\S]*?applyMessageCompletion\(/, `${label}: live completion metadata should reach the active message`); assert.match(panel, /case 'run_complete':[\s\S]*?setMessageCreatedAt\([\s\S]*?data\?\.endedAt/, `${label}: assistant sent time should use the terminal timestamp`); assert.match(panel, /function rebindRestoredMessageControls\(\)[\s\S]*?rebindMessageInfoToggles\(\)/, `${label}: restored messages should regain click behavior`); assert.match(panel, /createdAt: messageCreatedAt\(msgEl\)/, `${label}: durable history should preserve each message timestamp`); const createdAtReader = panel.match(/function messageCreatedAt\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; assert.doesNotMatch(createdAtReader, /Date\.now\(\)/, `${label}: unknown legacy timestamps must remain unknown`); - const bindToggle = panel.match(/function bindMessageInfoToggle\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; assert.doesNotMatch(bindToggle, /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`); @@ -81086,4 +81092,199 @@ test('sidepanels reveal persisted message info while verbose gates completion de assert.equal(messageInfoSources[1], messageInfoSources[0], 'message-info behavior should stay byte-identical across browsers'); }); +test('message info toggles behaviorally through a semantic button, terminal replay, and restore in both builds', async () => { + const { buildMessageInfoPills } = await import( + pathToFileURL(path.join(ROOT, 'src/chrome/src/message-info.js')).href + ); + + const fakeMessagesEl = { + querySelectorAll: () => [], + }; + + for (const [label, prefix] of [ + ['chrome', 'src/chrome'], + ['firefox', 'src/firefox'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); + const blockStart = panel.indexOf('function messageCreatedAt(msgEl) {'); + const blockEnd = panel.indexOf('function addMessage(', blockStart); + assert.notEqual(blockStart, -1, `${label}: message-info block start missing`); + assert.notEqual(blockEnd, -1, `${label}: message-info block boundary missing`); + const block = panel.slice(blockStart, blockEnd); + + const sandbox = { + document: { + createElement: (tag) => fakeDomElement('', tag), + }, + messagesEl: fakeMessagesEl, + buildMessageInfoPills, + verboseMode: true, + getLocale: () => 'en-GB', + t: (key) => key, + schedulePersist: () => {}, + }; + const { messageCreatedAt, setMessageCreatedAt, messageCompletionFromElement, renderMessageInfo, toggleMessageInfo, bindMessageInfoToggle, applyMessageCompletion } = vm.runInNewContext( + `(() => { ${block} return { messageCreatedAt, setMessageCreatedAt, messageCompletionFromElement, renderMessageInfo, toggleMessageInfo, bindMessageInfoToggle, applyMessageCompletion }; })()`, + sandbox, + ); + + const msgEl = fakeDomElement('message assistant'); + assert.equal(messageCreatedAt(msgEl), undefined, `${label}: unknown timestamps start unknown`); + setMessageCreatedAt(msgEl, 1734000000000); + 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. + 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.type, 'button', `${label}: the toggle should be a real button control`); + 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`); + 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'); + 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`); + + // Live completion metadata reaches the datasets and renders in verbose mode. + applyMessageCompletion(msgEl, { + inputTokens: 1000, + outputTokens: 600, + totalTokens: 1600, + durationMs: 5000, + finishReason: 'stop', + }); + assert.equal(messageCompletionFromElement(msgEl).finishReason, 'stop', `${label}: completion should reach the message datasets`); + const finishPill = openRow.children.find((child) => child.className.includes('message-info-finish')); + assert.ok(finishPill, `${label}: the finish-reason pill should render in verbose mode`); + + // Terminal replay: run_complete with endedAt replaces the sent time. + setMessageCreatedAt(msgEl, 1734000123456, { replace: true }); + assert.equal(messageCreatedAt(msgEl), 1734000123456, `${label}: run_complete endedAt should win over the request-start time`); + + // Restore: a fresh element rebuilt from persisted datasets and the open + // class (messagesEl.innerHTML round-trip) must regain the same behavior + // and retained metadata without inventing a sent time. + const restored = fakeDomElement('message assistant'); + restored.dataset.messageCreatedAt = String(1734000123456); + restored.dataset.messageInputTokens = '1000'; + restored.dataset.messageOutputTokens = '600'; + restored.dataset.messageTotalTokens = '1600'; + restored.dataset.messageDurationMs = '5000'; + restored.dataset.messageFinishReason = 'stop'; + restored.classList.add('message-info-open'); + bindMessageInfoToggle(restored); + 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'); + assert.ok(restoredRow, `${label}: restored open rows should render`); + assert.equal(restoredRow.hidden, false, `${label}: restored open rows should stay visible`); + assert.ok( + restoredRow.children.some((child) => child.className.includes('message-info-finish')), + `${label}: restored completion metrics should be retained`, + ); + + // Keyboard-equivalent activation: the toggle button itself toggles. + restoredToggle.dispatch('click', { target: restoredToggle }); + assert.equal(restored.classList.contains('message-info-open'), false, `${label}: the toggle button should close the row`); + 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`); + + // 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-toggle'), false, `${label}: legacy messages should not expose a toggle`); + } + + function fakeDomElement(className = '', tag = 'div') { + const element = { + tagName: String(tag || 'div').toUpperCase(), + className, + id: '', + type: 'button', + tabIndex: undefined, + hidden: false, + title: '', + textContent: '', + parentNode: null, + children: [], + dataset: {}, + attributes: {}, + classList: { + _set: new Set(className.split(/\s+/).filter(Boolean)), + add(name) { this._set.add(name); }, + remove(name) { this._set.delete(name); }, + contains(name) { return this._set.has(name); }, + toggle(name) { + if (this._set.has(name)) { this._set.delete(name); return false; } + this._set.add(name); return true; + }, + }, + _listeners: {}, + setAttribute(name, value) { this.attributes[name] = String(value); }, + appendChild(child) { + child.parentNode = this; + this.children.push(child); + return child; + }, + replaceChildren(...items) { + this.children = items; + 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; + } + return null; + }, + matches(selector) { + return selector.split(',').some((part) => { + return part.trim().split(/[\s.]+/).filter(Boolean).every((cls) => { + return this.classList.contains(cls); + }); + }); + }, + closest(selector) { + const interactiveTags = new Set(['a', 'button', 'input', 'textarea', 'select', 'summary']); + const interactive = selector.split(',').some((part) => { + const trimmed = part.trim(); + if (interactiveTags.has(trimmed)) return this.tagName === trimmed.toUpperCase(); + return false; + }); + return interactive ? this : null; + }, + addEventListener(type, handler) { + (this._listeners[type] ||= []).push(handler); + }, + dispatch(type, event = {}) { + const dispatchEvent = { + ...event, + type, + target: event.target || element, + preventDefault() {}, + stopPropagation() {}, + }; + for (const handler of this._listeners[type] || []) handler.call(this, dispatchEvent); + }, + }; + return element; + } +}); + await run(); From e7dd541f80bece46a8dd8aa30b30fcb1ec885631 Mon Sep 17 00:00:00 2001 From: Barack Sokullu Date: Sun, 16 Aug 2026 11:07:32 +0300 Subject: [PATCH 5/5] Fix message info streaming and accessibility --- src/chrome/src/agent/agent.js | 9 +++++ src/chrome/src/message-info.js | 5 ++- src/chrome/src/providers/anthropic.js | 8 +++- src/chrome/src/providers/azure-openai.js | 8 +++- src/chrome/src/providers/llamacpp.js | 8 +++- src/chrome/src/providers/openai.js | 18 +++++++-- src/chrome/src/ui/sidepanel.js | 47 +++++++++++++++++------ src/chrome/styles/sidepanel.css | 38 +++++++++++++++--- src/firefox/src/agent/agent.js | 9 +++++ src/firefox/src/message-info.js | 5 ++- src/firefox/src/providers/anthropic.js | 8 +++- src/firefox/src/providers/azure-openai.js | 8 +++- src/firefox/src/providers/llamacpp.js | 8 +++- src/firefox/src/providers/openai.js | 18 +++++++-- src/firefox/src/ui/sidepanel.js | 47 +++++++++++++++++------ src/firefox/styles/sidepanel.css | 38 +++++++++++++++--- test/run.js | 32 ++++++++++----- 17 files changed, 255 insertions(+), 59 deletions(-) diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index fd89c3510..218d0a1bb 100644 --- a/src/chrome/src/agent/agent.js +++ b/src/chrome/src/agent/agent.js @@ -1920,6 +1920,7 @@ export class Agent extends LoopDetector { let reasoningContent = ''; let usage = null; let responseItems = null; + let finishReason = ''; let sawCompleted = false; let usageRecorded = false; const toolCalls = new Map(); @@ -1987,6 +1988,13 @@ export class Agent extends LoopDetector { } else if (chunk?.type === 'done') { if (Array.isArray(chunk.responseItems)) responseItems = chunk.responseItems; if (chunk.usage) usage = chunk.usage; + finishReason = String( + chunk.finishReason + ?? chunk.finish_reason + ?? chunk.stopReason + ?? chunk.stop_reason + ?? '', + ); sawCompleted = true; break; } @@ -2013,6 +2021,7 @@ export class Agent extends LoopDetector { toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null, usage, responseItems, + finishReason, }; const after = await recordUsage(); if (after) result.costAllowanceMessage = after; diff --git a/src/chrome/src/message-info.js b/src/chrome/src/message-info.js index 7df244172..23e3d9a0f 100644 --- a/src/chrome/src/message-info.js +++ b/src/chrome/src/message-info.js @@ -49,6 +49,7 @@ function formatNumber(value, locale, maximumFractionDigits = 0) { export function aggregateMessageCompletion(current, result, durationMs) { const previous = current || {}; const usage = result?.usage || {}; + const reportedFinishReason = finishReason(result); const inputTokens = firstPositiveInteger( usage.prompt_tokens, usage.input_tokens, @@ -68,7 +69,9 @@ export function aggregateMessageCompletion(current, result, durationMs) { outputTokens: firstPositiveInteger(previous.outputTokens) + outputTokens, totalTokens: firstPositiveInteger(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), durationMs: firstPositiveInteger(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), - finishReason: finishReason(result) || String(previous.finishReason || '').slice(0, 80), + finishReason: reportedFinishReason || (Object.hasOwn(result || {}, 'finishReason') + ? '' + : String(previous.finishReason || '').slice(0, 80)), }; } diff --git a/src/chrome/src/providers/anthropic.js b/src/chrome/src/providers/anthropic.js index 6f478426f..2bf08dfa5 100644 --- a/src/chrome/src/providers/anthropic.js +++ b/src/chrome/src/providers/anthropic.js @@ -318,6 +318,7 @@ export class AnthropicProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let sawUsage = false; + let stopReason = ''; const accumulatedUsage = {}; const updateUsage = (usage) => { if (!usage || typeof usage !== 'object') return; @@ -390,6 +391,7 @@ export class AnthropicProvider extends BaseLLMProvider { updateUsage(event.message?.usage); } else if (event.type === 'message_delta') { updateUsage(event.usage); + if (event.delta?.stop_reason != null) stopReason = String(event.delta.stop_reason); } else if (event.type === 'content_block_delta') { if (event.delta?.type === 'text_delta') { yield { type: 'text', content: event.delta.text }; @@ -409,7 +411,11 @@ export class AnthropicProvider extends BaseLLMProvider { } else if (event.type === 'message_stop') { const usage = usageChunk(); if (usage) yield { type: 'usage', usage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(stopReason ? { finishReason: stopReason } : {}), + }; return; } } diff --git a/src/chrome/src/providers/azure-openai.js b/src/chrome/src/providers/azure-openai.js index 045c31896..06f58f987 100644 --- a/src/chrome/src/providers/azure-openai.js +++ b/src/chrome/src/providers/azure-openai.js @@ -158,6 +158,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; try { @@ -179,7 +180,11 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + }; return; } let json; @@ -205,6 +210,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { `${this.name} stream was blocked by the Azure content filter.`, ); } + if (choice?.finish_reason != null) terminalFinishReason = String(choice.finish_reason); const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { diff --git a/src/chrome/src/providers/llamacpp.js b/src/chrome/src/providers/llamacpp.js index 8fd285ebd..2f5ea0415 100644 --- a/src/chrome/src/providers/llamacpp.js +++ b/src/chrome/src/providers/llamacpp.js @@ -124,6 +124,7 @@ export class LlamaCppProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; @@ -148,7 +149,11 @@ export class LlamaCppProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + }; return; } let json; @@ -175,6 +180,7 @@ export class LlamaCppProvider extends BaseLLMProvider { if (choice?.finish_reason === 'content_filter') { throw this._askStreamTerminalError('llama.cpp stream was blocked by the provider content filter.'); } + if (choice?.finish_reason != null) terminalFinishReason = String(choice.finish_reason); const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { diff --git a/src/chrome/src/providers/openai.js b/src/chrome/src/providers/openai.js index 56dab64e4..3c86039c2 100644 --- a/src/chrome/src/providers/openai.js +++ b/src/chrome/src/providers/openai.js @@ -838,7 +838,13 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { if (response.usage) { yield { type: 'usage', usage: this._normalizeResponsesUsage(response.usage) }; } - yield { type: 'done', content: '', responseItems: response.output || [] }; + const finishReason = response.finish_reason ?? response.stop_reason; + yield { + type: 'done', + content: '', + responseItems: response.output || [], + ...(finishReason != null ? { finishReason: String(finishReason) } : {}), + }; return; } else if (event.type === 'response.incomplete') { // Incomplete is terminal (token limit / filter / etc.). Surface it @@ -949,6 +955,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { let buffer = ''; let finalUsage = null; let sawTerminalFinish = false; + let terminalFinishReason = ''; while (true) { let chunk; @@ -973,7 +980,11 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + }; return; } let json; @@ -1012,6 +1023,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finishReason != null) { sawTerminalFinish = true; + terminalFinishReason = String(finishReason); } const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; @@ -1028,7 +1040,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finalUsage) yield { type: 'usage', usage: finalUsage }; if (sawTerminalFinish) { - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', finishReason: terminalFinishReason }; return; } if (this._supportsInteractiveAskStreaming()) { diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index f7e483b11..39e023fcd 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -10460,8 +10460,9 @@ function messageCompletionFromElement(msgEl) { }; } -function renderMessageInfo(msgEl) { - if (!msgEl) return; +let messageInfoRowId = 0; + +function ensureMessageInfoElements(msgEl) { let row = msgEl.querySelector(':scope > .message-info'); if (!row) { row = document.createElement('div'); @@ -10469,6 +10470,31 @@ function renderMessageInfo(msgEl) { row.setAttribute('role', 'status'); msgEl.appendChild(row); } + if (!row.id) { + let id; + do { + id = `message-info-${++messageInfoRowId}`; + } 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.setAttribute('aria-controls', row.id); + toggle.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); + toggle.setAttribute('aria-label', t('sp.message_info.hint')); + toggle.title = t('sp.message_info.hint'); + return { row, toggle }; +} + +function renderMessageInfo(msgEl) { + if (!msgEl) return; + const { row } = ensureMessageInfoElements(msgEl); const pills = buildMessageInfoPills({ createdAt: messageCreatedAt(msgEl), completion: messageCompletionFromElement(msgEl), @@ -10494,7 +10520,7 @@ function messageInfoClickIsInteractive(target) { function toggleMessageInfo(msgEl) { const open = msgEl.classList.toggle('message-info-open'); - msgEl.setAttribute('aria-expanded', String(open)); + ensureMessageInfoElements(msgEl).toggle.setAttribute('aria-expanded', String(open)); renderMessageInfo(msgEl); schedulePersist(); } @@ -10502,21 +10528,18 @@ function toggleMessageInfo(msgEl) { function bindMessageInfoToggle(msgEl) { if (!msgEl?.matches?.('.message.user, .message.assistant')) return; if (!messageCreatedAt(msgEl)) return; - msgEl.tabIndex = 0; - msgEl.title = t('sp.message_info.hint'); - msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); + msgEl.removeAttribute('tabindex'); + msgEl.removeAttribute('aria-expanded'); + msgEl.removeAttribute('title'); + const { toggle } = ensureMessageInfoElements(msgEl); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); if (msgEl.__wbMessageInfoBound) return; msgEl.__wbMessageInfoBound = true; + toggle.addEventListener('click', () => toggleMessageInfo(msgEl)); msgEl.addEventListener('click', (event) => { if (messageInfoClickIsInteractive(event.target)) return; toggleMessageInfo(msgEl); }); - msgEl.addEventListener('keydown', (event) => { - if (event.target !== msgEl || (event.key !== 'Enter' && event.key !== ' ')) return; - event.preventDefault(); - toggleMessageInfo(msgEl); - }); } function rebindMessageInfoToggles() { @@ -10547,7 +10570,7 @@ function applyMessageCompletion(msgEl, completion = {}) { function refreshOpenMessageInfoRows() { messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { if (!messageCreatedAt(msgEl)) return; - msgEl.title = t('sp.message_info.hint'); + ensureMessageInfoElements(msgEl); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); }); } diff --git a/src/chrome/styles/sidepanel.css b/src/chrome/styles/sidepanel.css index 2455b599c..dc8160934 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -1383,12 +1383,6 @@ body { cursor: pointer; } -.message.user:focus-visible, -.message.assistant:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - .message.assistant.assistant-awaiting-content { display: none; } @@ -1460,6 +1454,38 @@ 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.user .message-info-toggle { + align-self: flex-end; +} + +.message-info-toggle:hover, +.message-info-toggle:focus-visible { + background: var(--overlay-bg); + color: var(--text-primary); + opacity: 1; +} + +.message-info-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .message-info { display: flex; flex-wrap: wrap; diff --git a/src/firefox/src/agent/agent.js b/src/firefox/src/agent/agent.js index 18dca8897..dcf4abd6d 100644 --- a/src/firefox/src/agent/agent.js +++ b/src/firefox/src/agent/agent.js @@ -1872,6 +1872,7 @@ export class Agent extends LoopDetector { let reasoningContent = ''; let usage = null; let responseItems = null; + let finishReason = ''; let sawCompleted = false; let usageRecorded = false; const toolCalls = new Map(); @@ -1939,6 +1940,13 @@ export class Agent extends LoopDetector { } else if (chunk?.type === 'done') { if (Array.isArray(chunk.responseItems)) responseItems = chunk.responseItems; if (chunk.usage) usage = chunk.usage; + finishReason = String( + chunk.finishReason + ?? chunk.finish_reason + ?? chunk.stopReason + ?? chunk.stop_reason + ?? '', + ); sawCompleted = true; break; } @@ -1965,6 +1973,7 @@ export class Agent extends LoopDetector { toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null, usage, responseItems, + finishReason, }; const after = await recordUsage(); if (after) result.costAllowanceMessage = after; diff --git a/src/firefox/src/message-info.js b/src/firefox/src/message-info.js index 7df244172..23e3d9a0f 100644 --- a/src/firefox/src/message-info.js +++ b/src/firefox/src/message-info.js @@ -49,6 +49,7 @@ function formatNumber(value, locale, maximumFractionDigits = 0) { export function aggregateMessageCompletion(current, result, durationMs) { const previous = current || {}; const usage = result?.usage || {}; + const reportedFinishReason = finishReason(result); const inputTokens = firstPositiveInteger( usage.prompt_tokens, usage.input_tokens, @@ -68,7 +69,9 @@ export function aggregateMessageCompletion(current, result, durationMs) { outputTokens: firstPositiveInteger(previous.outputTokens) + outputTokens, totalTokens: firstPositiveInteger(previous.totalTokens) + (reportedTotal || inputTokens + outputTokens), durationMs: firstPositiveInteger(previous.durationMs) + (Number.isFinite(elapsed) && elapsed > 0 ? Math.round(elapsed) : 0), - finishReason: finishReason(result) || String(previous.finishReason || '').slice(0, 80), + finishReason: reportedFinishReason || (Object.hasOwn(result || {}, 'finishReason') + ? '' + : String(previous.finishReason || '').slice(0, 80)), }; } diff --git a/src/firefox/src/providers/anthropic.js b/src/firefox/src/providers/anthropic.js index 15b29da98..a610945e6 100644 --- a/src/firefox/src/providers/anthropic.js +++ b/src/firefox/src/providers/anthropic.js @@ -316,6 +316,7 @@ export class AnthropicProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let sawUsage = false; + let stopReason = ''; const accumulatedUsage = {}; const updateUsage = (usage) => { if (!usage || typeof usage !== 'object') return; @@ -388,6 +389,7 @@ export class AnthropicProvider extends BaseLLMProvider { updateUsage(event.message?.usage); } else if (event.type === 'message_delta') { updateUsage(event.usage); + if (event.delta?.stop_reason != null) stopReason = String(event.delta.stop_reason); } else if (event.type === 'content_block_delta') { if (event.delta?.type === 'text_delta') { yield { type: 'text', content: event.delta.text }; @@ -407,7 +409,11 @@ export class AnthropicProvider extends BaseLLMProvider { } else if (event.type === 'message_stop') { const usage = usageChunk(); if (usage) yield { type: 'usage', usage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(stopReason ? { finishReason: stopReason } : {}), + }; return; } } diff --git a/src/firefox/src/providers/azure-openai.js b/src/firefox/src/providers/azure-openai.js index 79d6708d2..625241aed 100644 --- a/src/firefox/src/providers/azure-openai.js +++ b/src/firefox/src/providers/azure-openai.js @@ -155,6 +155,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; try { @@ -176,7 +177,11 @@ export class AzureOpenAIProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + }; return; } let json; @@ -202,6 +207,7 @@ export class AzureOpenAIProvider extends BaseLLMProvider { `${this.name} stream was blocked by the Azure content filter.`, ); } + if (choice?.finish_reason != null) terminalFinishReason = String(choice.finish_reason); const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { diff --git a/src/firefox/src/providers/llamacpp.js b/src/firefox/src/providers/llamacpp.js index 42a436ece..5282dc60c 100644 --- a/src/firefox/src/providers/llamacpp.js +++ b/src/firefox/src/providers/llamacpp.js @@ -124,6 +124,7 @@ export class LlamaCppProvider extends BaseLLMProvider { const decoder = new TextDecoder(); let buffer = ''; let finalUsage = null; + let terminalFinishReason = ''; while (true) { let chunk; @@ -148,7 +149,11 @@ export class LlamaCppProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + }; return; } let json; @@ -175,6 +180,7 @@ export class LlamaCppProvider extends BaseLLMProvider { if (choice?.finish_reason === 'content_filter') { throw this._askStreamTerminalError('llama.cpp stream was blocked by the provider content filter.'); } + if (choice?.finish_reason != null) terminalFinishReason = String(choice.finish_reason); const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; if (typeof reasoningDelta === 'string' && reasoningDelta) { diff --git a/src/firefox/src/providers/openai.js b/src/firefox/src/providers/openai.js index 95cb8dca6..f82beddf3 100644 --- a/src/firefox/src/providers/openai.js +++ b/src/firefox/src/providers/openai.js @@ -838,7 +838,13 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { if (response.usage) { yield { type: 'usage', usage: this._normalizeResponsesUsage(response.usage) }; } - yield { type: 'done', content: '', responseItems: response.output || [] }; + const finishReason = response.finish_reason ?? response.stop_reason; + yield { + type: 'done', + content: '', + responseItems: response.output || [], + ...(finishReason != null ? { finishReason: String(finishReason) } : {}), + }; return; } else if (event.type === 'response.incomplete') { // Incomplete is terminal (token limit / filter / etc.). Surface it @@ -949,6 +955,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { let buffer = ''; let finalUsage = null; let sawTerminalFinish = false; + let terminalFinishReason = ''; while (true) { let chunk; @@ -973,7 +980,11 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { const payload = trimmed.slice(6); if (payload === '[DONE]') { if (finalUsage) yield { type: 'usage', usage: finalUsage }; - yield { type: 'done', content: '' }; + yield { + type: 'done', + content: '', + ...(terminalFinishReason ? { finishReason: terminalFinishReason } : {}), + }; return; } let json; @@ -1012,6 +1023,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finishReason != null) { sawTerminalFinish = true; + terminalFinishReason = String(finishReason); } const delta = choice?.delta; const reasoningDelta = delta?.reasoning_content || delta?.reasoning; @@ -1028,7 +1040,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { } if (finalUsage) yield { type: 'usage', usage: finalUsage }; if (sawTerminalFinish) { - yield { type: 'done', content: '' }; + yield { type: 'done', content: '', finishReason: terminalFinishReason }; return; } if (this._supportsInteractiveAskStreaming()) { diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index 5f21dedc5..86996a75e 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -10144,8 +10144,9 @@ function messageCompletionFromElement(msgEl) { }; } -function renderMessageInfo(msgEl) { - if (!msgEl) return; +let messageInfoRowId = 0; + +function ensureMessageInfoElements(msgEl) { let row = msgEl.querySelector(':scope > .message-info'); if (!row) { row = document.createElement('div'); @@ -10153,6 +10154,31 @@ function renderMessageInfo(msgEl) { row.setAttribute('role', 'status'); msgEl.appendChild(row); } + if (!row.id) { + let id; + do { + id = `message-info-${++messageInfoRowId}`; + } 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.setAttribute('aria-controls', row.id); + toggle.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); + toggle.setAttribute('aria-label', t('sp.message_info.hint')); + toggle.title = t('sp.message_info.hint'); + return { row, toggle }; +} + +function renderMessageInfo(msgEl) { + if (!msgEl) return; + const { row } = ensureMessageInfoElements(msgEl); const pills = buildMessageInfoPills({ createdAt: messageCreatedAt(msgEl), completion: messageCompletionFromElement(msgEl), @@ -10178,7 +10204,7 @@ function messageInfoClickIsInteractive(target) { function toggleMessageInfo(msgEl) { const open = msgEl.classList.toggle('message-info-open'); - msgEl.setAttribute('aria-expanded', String(open)); + ensureMessageInfoElements(msgEl).toggle.setAttribute('aria-expanded', String(open)); renderMessageInfo(msgEl); schedulePersist(); } @@ -10186,21 +10212,18 @@ function toggleMessageInfo(msgEl) { function bindMessageInfoToggle(msgEl) { if (!msgEl?.matches?.('.message.user, .message.assistant')) return; if (!messageCreatedAt(msgEl)) return; - msgEl.tabIndex = 0; - msgEl.title = t('sp.message_info.hint'); - msgEl.setAttribute('aria-expanded', String(msgEl.classList.contains('message-info-open'))); + msgEl.removeAttribute('tabindex'); + msgEl.removeAttribute('aria-expanded'); + msgEl.removeAttribute('title'); + const { toggle } = ensureMessageInfoElements(msgEl); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); if (msgEl.__wbMessageInfoBound) return; msgEl.__wbMessageInfoBound = true; + toggle.addEventListener('click', () => toggleMessageInfo(msgEl)); msgEl.addEventListener('click', (event) => { if (messageInfoClickIsInteractive(event.target)) return; toggleMessageInfo(msgEl); }); - msgEl.addEventListener('keydown', (event) => { - if (event.target !== msgEl || (event.key !== 'Enter' && event.key !== ' ')) return; - event.preventDefault(); - toggleMessageInfo(msgEl); - }); } function rebindMessageInfoToggles() { @@ -10231,7 +10254,7 @@ function applyMessageCompletion(msgEl, completion = {}) { function refreshOpenMessageInfoRows() { messagesEl.querySelectorAll(':scope > .message.user, :scope > .message.assistant').forEach((msgEl) => { if (!messageCreatedAt(msgEl)) return; - msgEl.title = t('sp.message_info.hint'); + ensureMessageInfoElements(msgEl); if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); }); } diff --git a/src/firefox/styles/sidepanel.css b/src/firefox/styles/sidepanel.css index 88d25d52d..f2b2986e1 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -1261,12 +1261,6 @@ body { cursor: pointer; } -.message.user:focus-visible, -.message.assistant:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 3px; -} - .message.assistant.assistant-awaiting-content { display: none; } @@ -1338,6 +1332,38 @@ 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.user .message-info-toggle { + align-self: flex-end; +} + +.message-info-toggle:hover, +.message-info-toggle:focus-visible { + background: var(--overlay-bg); + color: var(--text-primary); + opacity: 1; +} + +.message-info-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + .message-info { display: flex; flex-wrap: wrap; diff --git a/test/run.js b/test/run.js index 1f0df6956..d8fbf0de9 100644 --- a/test/run.js +++ b/test/run.js @@ -45740,7 +45740,7 @@ test('Chat Completions streaming rejects premature EOF and accepts terminal comp } assert.deepEqual(complete, [ { type: 'text', content: 'done' }, - { type: 'done', content: '' }, + { type: 'done', content: '', finishReason: 'stop' }, ], `${label}: terminal finish_reason should complete the stream`); } } finally { @@ -47332,6 +47332,7 @@ test('OpenAI-compatible Ask providers consume text, tool, usage, and DONE fixtur globalThis.fetch = async () => new Response([ `data: ${JSON.stringify({ choices: [{ delta: { content: `${id} answer` } }] })}\n\n`, `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'read_page', arguments: '{}' } }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] })}\n\n`, `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } })}\n\n`, 'data: [DONE]\n\n', ].join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); @@ -47354,7 +47355,7 @@ test('OpenAI-compatible Ask providers consume text, tool, usage, and DONE fixtur }], }, { type: 'usage', usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } }, - { type: 'done', content: '' }, + { type: 'done', content: '', finishReason: 'tool_calls' }, ], `${label}/${id}: compatible stream fixture mismatch`); } } @@ -47376,6 +47377,7 @@ test('llama.cpp Ask streams consume OpenAI-compatible fixtures and require DONE' `data: ${JSON.stringify({ choices: [{ delta: { reasoning_content: 'Think.' } }] })}\n\n`, `data: ${JSON.stringify({ choices: [{ delta: { content: 'Local answer.' } }] })}\n\n`, `data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'read_page', arguments: '{}' } }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] })}\n\n`, `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } })}\n\n`, 'data: [DONE]\n\n', ].join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }); @@ -47394,7 +47396,7 @@ test('llama.cpp Ask streams consume OpenAI-compatible fixtures and require DONE' }], }, { type: 'usage', usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 } }, - { type: 'done', content: '' }, + { type: 'done', content: '', finishReason: 'tool_calls' }, ]); globalThis.fetch = async () => new Response( @@ -47441,7 +47443,7 @@ test('Anthropic Ask streams require message_stop and propagate in-stream error e const completeSse = [ `data: ${JSON.stringify({ type: 'message_start', message: { usage: { input_tokens: 4, output_tokens: 1 } } })}\n\n`, `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Claude answer.' } })}\n\n`, - `data: ${JSON.stringify({ type: 'message_delta', usage: { output_tokens: 3 } })}\n\n`, + `data: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 3 } })}\n\n`, `data: ${JSON.stringify({ type: 'message_stop' })}\n\n`, ].join(''); globalThis.fetch = async () => new Response(completeSse, { @@ -47453,6 +47455,7 @@ test('Anthropic Ask streams require message_stop and propagate in-stream error e assert.equal(chunks[0]?.content, 'Claude answer.'); assert.equal(chunks[1]?.type, 'usage'); assert.equal(chunks[2]?.type, 'done'); + assert.equal(chunks[2]?.finishReason, 'end_turn'); globalThis.fetch = async () => new Response( `data: ${JSON.stringify({ type: 'content_block_delta', delta: { type: 'text_delta', text: 'Partial' } })}\n\n`, @@ -47499,6 +47502,7 @@ test('Azure OpenAI Ask streams require DONE and distinguish terminal API errors' }); const completeSse = [ `data: ${JSON.stringify({ choices: [{ delta: { content: 'Azure answer.' } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\n`, `data: ${JSON.stringify({ choices: [], usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 } })}\n\n`, 'data: [DONE]\n\n', ].join(''); @@ -47511,6 +47515,7 @@ test('Azure OpenAI Ask streams require DONE and distinguish terminal API errors' assert.equal(chunks[0]?.content, 'Azure answer.'); assert.equal(chunks[1]?.type, 'usage'); assert.equal(chunks[2]?.type, 'done'); + assert.equal(chunks[2]?.finishReason, 'stop'); globalThis.fetch = async () => new Response( `data: ${JSON.stringify({ choices: [{ delta: { content: 'Partial' } }] })}\n\n`, @@ -47581,7 +47586,7 @@ test('Ask stream aggregation exposes text live but withholds tool calls until re }; await completedGate; yield { type: 'usage', usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 } }; - yield { type: 'done', responseItems }; + yield { type: 'done', responseItems, stop_reason: 'tool_calls' }; }, }; const agent = new AgentClass({}); @@ -47608,6 +47613,7 @@ test('Ask stream aggregation exposes text live but withholds tool calls until re assert.equal(result.content, 'Checking', `${label}: final text aggregation mismatch`); assert.equal(result.toolCalls?.[0]?.function?.name, 'read_page', `${label}: completed tool call missing`); assert.deepEqual(result.responseItems, responseItems, `${label}: completed replay items missing`); + assert.equal(result.finishReason, 'tool_calls', `${label}: terminal stream reasons should reach aggregation`); } }); @@ -48945,6 +48951,7 @@ test('official GPT-5.6 streaming uses Responses events for text, tools, and usag { type: 'response.completed', response: { + stop_reason: 'stop', usage: { input_tokens: 8, output_tokens: 3, total_tokens: 11 }, output: [ { type: 'message', content: [{ type: 'output_text', text: 'Hello' }] }, @@ -48977,6 +48984,7 @@ test('official GPT-5.6 streaming uses Responses events for text, tools, and usag assert.equal(chunks[2].type, 'usage'); assert.equal(chunks[2].usage.prompt_tokens, 8); assert.equal(chunks.at(-1).type, 'done'); + assert.equal(chunks.at(-1).finishReason, 'stop'); assert.equal(chunks.at(-1).responseItems.length, 2); } } finally { @@ -81029,6 +81037,9 @@ test('message info aggregates model calls into verbose completion pills', async 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, @@ -81066,19 +81077,22 @@ 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 bindMessageInfoToggle\([\s\S]*?addEventListener\('click'[\s\S]*?toggleMessageInfo\(/, - `${label}: clicking a chat message should toggle its info row`, + /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`, ); + 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] || ''; + assert.doesNotMatch(bindMessageInfoToggle, /msgEl\.tabIndex|msgEl\.addEventListener\('keydown'/, `${label}: the generic message container must not masquerade as a keyboard control`); assert.match(panel, /case 'message_info':[\s\S]*?applyMessageCompletion\(/, `${label}: live completion metadata should reach the active message`); assert.match(panel, /case 'run_complete':[\s\S]*?setMessageCreatedAt\([\s\S]*?data\?\.endedAt/, `${label}: assistant sent time should use the terminal timestamp`); assert.match(panel, /function rebindRestoredMessageControls\(\)[\s\S]*?rebindMessageInfoToggles\(\)/, `${label}: restored messages should regain click behavior`); assert.match(panel, /createdAt: messageCreatedAt\(msgEl\)/, `${label}: durable history should preserve each message timestamp`); const createdAtReader = panel.match(/function messageCreatedAt\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; assert.doesNotMatch(createdAtReader, /Date\.now\(\)/, `${label}: unknown legacy timestamps must remain unknown`); - const bindToggle = panel.match(/function bindMessageInfoToggle\(msgEl\) \{[\s\S]*?\n\}/)?.[0] || ''; - assert.doesNotMatch(bindToggle, /setMessageCreatedAt\(/, `${label}: legacy restored messages must not invent a sent time while rebinding`); + 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`); 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`); }