diff --git a/src/chrome/src/agent/agent.js b/src/chrome/src/agent/agent.js index 99b44f206..09d10d972 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'; @@ -1997,6 +1998,8 @@ export class Agent extends LoopDetector { let reasoningContent = ''; let usage = null; let responseItems = null; + let finishReason = ''; + let terminalRaw = null; let sawCompleted = false; let usageRecorded = false; const toolCalls = new Map(); @@ -2064,6 +2067,14 @@ 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 + ?? '', + ); + if (chunk.raw) terminalRaw = chunk.raw; sawCompleted = true; break; } @@ -2090,6 +2101,8 @@ export class Agent extends LoopDetector { toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null, usage, responseItems, + finishReason, + ...(terminalRaw ? { raw: terminalRaw } : {}), }; const after = await recordUsage(); if (after) result.costAllowanceMessage = after; @@ -25059,6 +25072,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; @@ -25242,7 +25256,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, @@ -25337,6 +25351,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..23e3d9a0f --- /dev/null +++ b/src/chrome/src/message-info.js @@ -0,0 +1,120 @@ +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 firstPositiveInteger(...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 + ?? '', + ).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 reportedFinishReason = finishReason(result); + const inputTokens = firstPositiveInteger( + usage.prompt_tokens, + usage.input_tokens, + usage.promptTokens, + usage.inputTokens, + ); + const outputTokens = firstPositiveInteger( + usage.completion_tokens, + usage.output_tokens, + usage.completionTokens, + usage.outputTokens, + ); + const reportedTotal = firstPositiveInteger(usage.total_tokens, usage.totalTokens); + const elapsed = Number(durationMs); + return { + 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: reportedFinishReason || (Object.hasOwn(result || {}, 'finishReason') + ? '' + : 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 = firstPositiveInteger(completion.outputTokens); + const displayedTokens = outputTokens || firstPositiveInteger(completion.totalTokens); + const durationMs = firstPositiveInteger(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/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/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..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..803dd1238 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) { @@ -192,6 +198,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..95804b3ed 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); @@ -838,7 +843,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 +960,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { let buffer = ''; let finalUsage = null; let sawTerminalFinish = false; + let terminalFinishReason = ''; while (true) { let chunk; @@ -973,7 +985,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 +1028,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 +1045,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/locales/ar.js b/src/chrome/src/ui/locales/ar.js index 0ba5cbb8f..40bdfa2cc 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', 'sp.slash.print': 'فتح مربع حوار الطباعة الأصلي للصفحة الحالية', 'sp.print.error': 'تعذر فتح مربع حوار الطباعة: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/bn.js b/src/chrome/src/ui/locales/bn.js index 93fd61d76..3dc5f38db 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.slash.print': 'বর্তমান পৃষ্ঠার নেটিভ প্রিন্ট ডায়ালগ খুলুন', 'sp.print.error': 'প্রিন্ট ডায়ালগ খোলা যায়নি: {msg}', 'sp.streaming.fallback': 'প্রতিক্রিয়া স্ট্রিম বাধাগ্রস্ত হয়েছে; স্ট্রিমিং ছাড়া এই Ask পালাটি আবার চেষ্টা করা হচ্ছে।', diff --git a/src/chrome/src/ui/locales/de.js b/src/chrome/src/ui/locales/de.js index fb2b4270e..8a1ffb860 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', 'sp.slash.print': 'Nativen Druckdialog der aktuellen Seite öffnen', 'sp.print.error': 'Druckdialog konnte nicht geöffnet werden: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/en.js b/src/chrome/src/ui/locales/en.js index f884a52b3..56c8e2863 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.slash.print': 'Open the current page’s native print dialog', 'sp.print.error': 'Could not open the print dialog: {msg}', 'sp.streaming.fallback': 'Response streaming was interrupted; retrying this Ask turn without streaming.', diff --git a/src/chrome/src/ui/locales/es.js b/src/chrome/src/ui/locales/es.js index 75da3b678..097f94923 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', 'sp.slash.print': 'Abrir el diálogo de impresión nativo de la página actual', 'sp.print.error': 'No se pudo abrir el diálogo de impresión: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/fa.js b/src/chrome/src/ui/locales/fa.js index cca2ddde2..9831fe275 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.slash.print': 'باز کردن گفت‌وگوی چاپ بومی صفحهٔ فعلی', 'sp.print.error': 'گفت‌وگوی چاپ باز نشد: {msg}', 'sp.streaming.fallback': 'جریان پاسخ قطع شد؛ این نوبت Ask بدون پخش جریانی دوباره امتحان می‌شود.', diff --git a/src/chrome/src/ui/locales/fr.js b/src/chrome/src/ui/locales/fr.js index d72fff4a4..c21cbaccc 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', 'sp.slash.print': 'Ouvrir la boîte de dialogue d’impression native de la page actuelle', 'sp.print.error': 'Impossible d’ouvrir la boîte de dialogue d’impression : {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/he.js b/src/chrome/src/ui/locales/he.js index a93f6abae..007383198 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', 'sp.slash.print': 'פתיחת תיבת הדו־שיח המקורית להדפסת הדף הנוכחי', 'sp.print.error': 'לא ניתן לפתוח את תיבת הדו־שיח להדפסה: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/hi.js b/src/chrome/src/ui/locales/hi.js index 792e902e9..4877e5ecb 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.slash.print': 'वर्तमान पृष्ठ का मूल प्रिंट संवाद खोलें', 'sp.print.error': 'प्रिंट संवाद नहीं खुल सका: {msg}', 'sp.streaming.fallback': 'प्रतिक्रिया स्ट्रीम बाधित हुई; इस Ask टर्न को बिना स्ट्रीमिंग के फिर से आज़माया जा रहा है।', diff --git a/src/chrome/src/ui/locales/id.js b/src/chrome/src/ui/locales/id.js index 0f8d3c1ac..ad79a0d4f 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', 'sp.slash.print': 'Buka dialog cetak bawaan halaman saat ini', 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/ja.js b/src/chrome/src/ui/locales/ja.js index 91652de68..6f0574016 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', 'sp.slash.print': '現在のページのネイティブ印刷ダイアログを開く', 'sp.print.error': '印刷ダイアログを開けませんでした: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/ko.js b/src/chrome/src/ui/locales/ko.js index e612d008e..889cbb853 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', 'sp.slash.print': '현재 페이지의 기본 인쇄 대화상자 열기', 'sp.print.error': '인쇄 대화상자를 열 수 없습니다: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/ms.js b/src/chrome/src/ui/locales/ms.js index 08d3160de..8ba1d6279 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', 'sp.slash.print': 'Buka dialog cetak asli halaman semasa', 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/nl.js b/src/chrome/src/ui/locales/nl.js index c32509cc2..196450427 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', 'sp.slash.print': 'Het systeemeigen afdrukvenster van de huidige pagina openen', 'sp.print.error': 'Het afdrukvenster kon niet worden geopend: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/pl.js b/src/chrome/src/ui/locales/pl.js index ad901f321..87ae2a615 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', 'sp.slash.print': 'Otwórz natywne okno drukowania bieżącej strony', 'sp.print.error': 'Nie udało się otworzyć okna drukowania: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/pt.js b/src/chrome/src/ui/locales/pt.js index 378fcd220..737dfb91a 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.slash.print': 'Abrir a caixa de diálogo de impressão nativa da página atual', 'sp.print.error': 'Não foi possível abrir a caixa de diálogo de impressão: {msg}', 'sp.streaming.fallback': 'A transmissão da resposta foi interrompida; tentando novamente esta interação Ask sem transmissão.', diff --git a/src/chrome/src/ui/locales/ru.js b/src/chrome/src/ui/locales/ru.js index 1e035f929..4723f56f1 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', 'sp.slash.print': 'Открыть системное диалоговое окно печати текущей страницы', 'sp.print.error': 'Не удалось открыть диалоговое окно печати: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/th.js b/src/chrome/src/ui/locales/th.js index a134e0f88..3a924244f 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', 'sp.slash.print': 'เปิดกล่องโต้ตอบการพิมพ์ดั้งเดิมของหน้าปัจจุบัน', 'sp.print.error': 'ไม่สามารถเปิดกล่องโต้ตอบการพิมพ์ได้: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/tl.js b/src/chrome/src/ui/locales/tl.js index e1d0a889c..d54530d00 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', 'sp.slash.print': 'Buksan ang native na dialog ng pag-print ng kasalukuyang pahina', 'sp.print.error': 'Hindi mabuksan ang dialog ng pag-print: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/tr.js b/src/chrome/src/ui/locales/tr.js index 39f3f30fa..f36cc9160 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', 'sp.slash.print': 'Geçerli sayfanın yerel yazdırma iletişim kutusunu aç', 'sp.print.error': 'Yazdırma iletişim kutusu açılamadı: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/uk.js b/src/chrome/src/ui/locales/uk.js index a4e1f8a8d..8bc9f4edc 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', 'sp.slash.print': 'Відкрити системне діалогове вікно друку поточної сторінки', 'sp.print.error': 'Не вдалося відкрити діалогове вікно друку: {msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/locales/vi.js b/src/chrome/src/ui/locales/vi.js index d90064312..daaef48ad 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.slash.print': 'Mở hộp thoại in gốc của trang hiện tại', 'sp.print.error': 'Không thể mở hộp thoại in: {msg}', '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.', diff --git a/src/chrome/src/ui/locales/zh.js b/src/chrome/src/ui/locales/zh.js index 462da9568..fe9b6e4ba 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', 'sp.slash.print': '打开当前页面的原生打印对话框', 'sp.print.error': '无法打开打印对话框:{msg}', ...chromeWebStoreLocale, diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index ca3c0bb59..ee918ce75 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, @@ -2396,7 +2397,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); @@ -4143,6 +4144,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; @@ -4198,6 +4200,7 @@ if (verboseBtn) { // Normal click → toggle verbose mode verboseMode = !verboseMode; verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); await chrome.storage.local.set({ verboseMode }).catch(() => {}); }); } @@ -4595,6 +4598,7 @@ async function applyActiveRunState(numericTabId, state) { finalContent: runUi.finalContent, submittedTurnDurable: state?.submittedTurnDurable === true, attachmentDeliveryState: runUi.attachmentDeliveryState || '', + endedAt: runUi.endedAt, }, }); } @@ -6144,6 +6148,7 @@ function renderAgentErrorUpdate(data, tabId = currentTabId, requestId = '', opti function rebindRestoredMessageControls() { restoreStagedScreenshotAttachments(); rebindCopyButtons(); + rebindMessageInfoToggles(); rebindCompactStepDetailsToggles(); rebindScreenshotSaveButtons(); rebindRetryButtons(); @@ -7490,6 +7495,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 @@ -8954,6 +8960,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(); @@ -9004,6 +9014,7 @@ function handleAgentUpdateMessage(msg) { break; case 'run_complete': + setMessageCreatedAt(eventAssistantEl || currentAssistantEl, data?.endedAt, { replace: true }); if (currentAssistantEl) finalizeSteps(currentAssistantEl); reconcileRunMessageAttachmentState( eventTabId, @@ -10505,6 +10516,148 @@ function messageAttachmentMetadata(msgEl) { }, item.dataset.deliveryState)); } +function messageCreatedAt(msgEl) { + const value = Number(msgEl?.dataset?.messageCreatedAt); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +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 : 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; +} + +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 || ''), + }; +} + +let messageInfoRowId = 0; + +function ensureMessageInfoElements(msgEl) { + let row = msgEl.querySelector(':scope > .message-info'); + if (!row) { + row = document.createElement('div'); + row.className = 'message-info'; + 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), + 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'); + ensureMessageInfoElements(msgEl).toggle.setAttribute('aria-expanded', String(open)); + renderMessageInfo(msgEl); + schedulePersist(); +} + +function bindMessageInfoToggle(msgEl) { + if (!msgEl?.matches?.('.message.user, .message.assistant')) return; + if (!messageCreatedAt(msgEl)) return; + 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); + }); +} + +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) => { + if (!messageCreatedAt(msgEl)) return; + ensureMessageInfoElements(msgEl); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + }); +} + function addMessage(role, content, options = {}) { const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; @@ -10555,6 +10708,10 @@ function addMessage(role, content, options = {}) { } else { messagesEl.appendChild(msgEl); } + if (role === 'user' || role === 'assistant') { + setMessageCreatedAt(msgEl, options.createdAt ?? Date.now()); + bindMessageInfoToggle(msgEl); + } if (role === 'error' && options.retryPayload && !textEl.classList.contains('cost-allowance-error')) { @@ -12554,6 +12711,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 a35f92b50..300023997 100644 --- a/src/chrome/styles/sidepanel.css +++ b/src/chrome/styles/sidepanel.css @@ -1419,6 +1419,11 @@ body { width: 95%; } +.message.user, +.message.assistant { + cursor: pointer; +} + .message.assistant.assistant-awaiting-content { display: none; } @@ -1490,6 +1495,72 @@ 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; + 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 89c239aa5..770272c76 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'; @@ -1920,6 +1921,8 @@ export class Agent extends LoopDetector { let reasoningContent = ''; let usage = null; let responseItems = null; + let finishReason = ''; + let terminalRaw = null; let sawCompleted = false; let usageRecorded = false; const toolCalls = new Map(); @@ -1987,6 +1990,14 @@ 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 + ?? '', + ); + if (chunk.raw) terminalRaw = chunk.raw; sawCompleted = true; break; } @@ -2013,6 +2024,8 @@ export class Agent extends LoopDetector { toolCalls: toolCalls.size ? [...toolCalls.entries()].sort(([a], [b]) => a - b).map(([, call]) => call) : null, usage, responseItems, + finishReason, + ...(terminalRaw ? { raw: terminalRaw } : {}), }; const after = await recordUsage(); if (after) result.costAllowanceMessage = after; @@ -19340,6 +19353,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; @@ -19513,7 +19527,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, @@ -19608,6 +19622,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..23e3d9a0f --- /dev/null +++ b/src/firefox/src/message-info.js @@ -0,0 +1,120 @@ +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 firstPositiveInteger(...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 + ?? '', + ).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 reportedFinishReason = finishReason(result); + const inputTokens = firstPositiveInteger( + usage.prompt_tokens, + usage.input_tokens, + usage.promptTokens, + usage.inputTokens, + ); + const outputTokens = firstPositiveInteger( + usage.completion_tokens, + usage.output_tokens, + usage.completionTokens, + usage.outputTokens, + ); + const reportedTotal = firstPositiveInteger(usage.total_tokens, usage.totalTokens); + const elapsed = Number(durationMs); + return { + 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: reportedFinishReason || (Object.hasOwn(result || {}, 'finishReason') + ? '' + : 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 = firstPositiveInteger(completion.outputTokens); + const displayedTokens = outputTokens || firstPositiveInteger(completion.totalTokens); + const durationMs = firstPositiveInteger(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/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/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..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..50a21b953 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) { @@ -192,6 +198,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..26458236d 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); @@ -838,7 +843,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 +960,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { let buffer = ''; let finalUsage = null; let sawTerminalFinish = false; + let terminalFinishReason = ''; while (true) { let chunk; @@ -973,7 +985,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 +1028,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 +1045,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/locales/ar.js b/src/firefox/src/ui/locales/ar.js index e345980d5..33ef8ae8f 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', 'sp.slash.print': 'فتح مربع حوار الطباعة الأصلي للصفحة الحالية', 'sp.print.error': 'تعذر فتح مربع حوار الطباعة: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/bn.js b/src/firefox/src/ui/locales/bn.js index 6066a1613..4bd0fb9a4 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.slash.print': 'বর্তমান পৃষ্ঠার নেটিভ প্রিন্ট ডায়ালগ খুলুন', 'sp.print.error': 'প্রিন্ট ডায়ালগ খোলা যায়নি: {msg}', 'sp.streaming.fallback': 'প্রতিক্রিয়া স্ট্রিম বাধাগ্রস্ত হয়েছে; স্ট্রিমিং ছাড়া এই Ask পালাটি আবার চেষ্টা করা হচ্ছে।', diff --git a/src/firefox/src/ui/locales/de.js b/src/firefox/src/ui/locales/de.js index 66b24c203..165ab2771 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', 'sp.slash.print': 'Nativen Druckdialog der aktuellen Seite öffnen', 'sp.print.error': 'Druckdialog konnte nicht geöffnet werden: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/en.js b/src/firefox/src/ui/locales/en.js index 1e8b4c97b..d89385da6 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.slash.print': 'Open the current page’s native print dialog', 'sp.print.error': 'Could not open the print dialog: {msg}', 'sp.streaming.fallback': 'Response streaming was interrupted; retrying this Ask turn without streaming.', diff --git a/src/firefox/src/ui/locales/es.js b/src/firefox/src/ui/locales/es.js index ebd04c2f6..2d7a9548d 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', 'sp.slash.print': 'Abrir el diálogo de impresión nativo de la página actual', 'sp.print.error': 'No se pudo abrir el diálogo de impresión: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/fa.js b/src/firefox/src/ui/locales/fa.js index 0222199a1..ff575e048 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.slash.print': 'باز کردن گفت‌وگوی چاپ بومی صفحهٔ فعلی', 'sp.print.error': 'گفت‌وگوی چاپ باز نشد: {msg}', 'sp.streaming.fallback': 'جریان پاسخ قطع شد؛ این نوبت Ask بدون پخش جریانی دوباره امتحان می‌شود.', diff --git a/src/firefox/src/ui/locales/fr.js b/src/firefox/src/ui/locales/fr.js index 51613cf23..36f51fcb6 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', 'sp.slash.print': 'Ouvrir la boîte de dialogue d’impression native de la page actuelle', 'sp.print.error': 'Impossible d’ouvrir la boîte de dialogue d’impression : {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/he.js b/src/firefox/src/ui/locales/he.js index a45e71cab..d7e048f81 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', 'sp.slash.print': 'פתיחת תיבת הדו־שיח המקורית להדפסת הדף הנוכחי', 'sp.print.error': 'לא ניתן לפתוח את תיבת הדו־שיח להדפסה: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/hi.js b/src/firefox/src/ui/locales/hi.js index 34de631e7..58bc8c882 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.slash.print': 'वर्तमान पृष्ठ का मूल प्रिंट संवाद खोलें', 'sp.print.error': 'प्रिंट संवाद नहीं खुल सका: {msg}', 'sp.streaming.fallback': 'प्रतिक्रिया स्ट्रीम बाधित हुई; इस Ask टर्न को बिना स्ट्रीमिंग के फिर से आज़माया जा रहा है।', diff --git a/src/firefox/src/ui/locales/id.js b/src/firefox/src/ui/locales/id.js index 82da8126a..1bc00842d 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', 'sp.slash.print': 'Buka dialog cetak bawaan halaman saat ini', 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/ja.js b/src/firefox/src/ui/locales/ja.js index 6497d2b95..55f6eccea 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', 'sp.slash.print': '現在のページのネイティブ印刷ダイアログを開く', 'sp.print.error': '印刷ダイアログを開けませんでした: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/ko.js b/src/firefox/src/ui/locales/ko.js index 3e8107012..f30f76157 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', 'sp.slash.print': '현재 페이지의 기본 인쇄 대화상자 열기', 'sp.print.error': '인쇄 대화상자를 열 수 없습니다: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/ms.js b/src/firefox/src/ui/locales/ms.js index 6bad5c42c..448ebe441 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', 'sp.slash.print': 'Buka dialog cetak asli halaman semasa', 'sp.print.error': 'Tidak dapat membuka dialog cetak: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/nl.js b/src/firefox/src/ui/locales/nl.js index 318cd5d4d..9c52377fc 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', 'sp.slash.print': 'Het systeemeigen afdrukvenster van de huidige pagina openen', 'sp.print.error': 'Het afdrukvenster kon niet worden geopend: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/pl.js b/src/firefox/src/ui/locales/pl.js index 7761ac4f8..f477b01de 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', 'sp.slash.print': 'Otwórz natywne okno drukowania bieżącej strony', 'sp.print.error': 'Nie udało się otworzyć okna drukowania: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/pt.js b/src/firefox/src/ui/locales/pt.js index 265eae55b..6b43fb270 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.slash.print': 'Abrir a caixa de diálogo de impressão nativa da página atual', 'sp.print.error': 'Não foi possível abrir a caixa de diálogo de impressão: {msg}', 'sp.streaming.fallback': 'A transmissão da resposta foi interrompida; tentando novamente esta interação Ask sem transmissão.', diff --git a/src/firefox/src/ui/locales/ru.js b/src/firefox/src/ui/locales/ru.js index 2ca5eab1f..fc3fe6c40 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', 'sp.slash.print': 'Открыть системное диалоговое окно печати текущей страницы', 'sp.print.error': 'Не удалось открыть диалоговое окно печати: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/th.js b/src/firefox/src/ui/locales/th.js index c879b68e7..1d6f5e0f5 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', 'sp.slash.print': 'เปิดกล่องโต้ตอบการพิมพ์ดั้งเดิมของหน้าปัจจุบัน', 'sp.print.error': 'ไม่สามารถเปิดกล่องโต้ตอบการพิมพ์ได้: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/tl.js b/src/firefox/src/ui/locales/tl.js index 1facb6058..611a48f55 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', 'sp.slash.print': 'Buksan ang native na dialog ng pag-print ng kasalukuyang pahina', 'sp.print.error': 'Hindi mabuksan ang dialog ng pag-print: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/tr.js b/src/firefox/src/ui/locales/tr.js index 7a3465325..fbce84d5c 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', 'sp.slash.print': 'Geçerli sayfanın yerel yazdırma iletişim kutusunu aç', 'sp.print.error': 'Yazdırma iletişim kutusu açılamadı: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/uk.js b/src/firefox/src/ui/locales/uk.js index 3727c8c2d..b2e95dd2d 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', 'sp.slash.print': 'Відкрити системне діалогове вікно друку поточної сторінки', 'sp.print.error': 'Не вдалося відкрити діалогове вікно друку: {msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/locales/vi.js b/src/firefox/src/ui/locales/vi.js index 6a54f664b..3babc281b 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.slash.print': 'Mở hộp thoại in gốc của trang hiện tại', 'sp.print.error': 'Không thể mở hộp thoại in: {msg}', '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.', diff --git a/src/firefox/src/ui/locales/zh.js b/src/firefox/src/ui/locales/zh.js index f2ee314d9..bc9cf5e82 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', 'sp.slash.print': '打开当前页面的原生打印对话框', 'sp.print.error': '无法打开打印对话框:{msg}', ...chromeWebStoreLocale, diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index dec9e0ddf..863da17b3 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, @@ -1807,7 +1808,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); @@ -3992,6 +3993,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; @@ -4044,6 +4046,7 @@ if (verboseBtn) { // Normal click → toggle verbose mode verboseMode = !verboseMode; verboseBtn.classList.toggle('active', verboseMode); + refreshOpenMessageInfoRows(); await browser.storage.local.set({ verboseMode }).catch(() => {}); }); } @@ -4432,6 +4435,7 @@ async function applyActiveRunState(numericTabId, state) { finalContent: runUi.finalContent, submittedTurnDurable: state?.submittedTurnDurable === true, attachmentDeliveryState: runUi.attachmentDeliveryState || '', + endedAt: runUi.endedAt, }, }); } @@ -5981,6 +5985,7 @@ function renderAgentErrorUpdate(data, tabId = currentTabId, requestId = '', opti function rebindRestoredMessageControls() { restoreStagedScreenshotAttachments(); rebindCopyButtons(); + rebindMessageInfoToggles(); rebindCompactStepDetailsToggles(); rebindScreenshotSaveButtons(); rebindRetryButtons(); @@ -7274,6 +7279,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 @@ -8442,6 +8448,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(); @@ -8489,6 +8499,7 @@ function handleAgentUpdateMessage(msg) { break; case 'run_complete': + setMessageCreatedAt(eventAssistantEl || currentAssistantEl, data?.endedAt, { replace: true }); if (currentAssistantEl) finalizeSteps(currentAssistantEl); reconcileRunMessageAttachmentState( eventTabId, @@ -10126,6 +10137,148 @@ function messageAttachmentMetadata(msgEl) { }, item.dataset.deliveryState)); } +function messageCreatedAt(msgEl) { + const value = Number(msgEl?.dataset?.messageCreatedAt); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +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 : 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; +} + +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 || ''), + }; +} + +let messageInfoRowId = 0; + +function ensureMessageInfoElements(msgEl) { + let row = msgEl.querySelector(':scope > .message-info'); + if (!row) { + row = document.createElement('div'); + row.className = 'message-info'; + 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), + 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'); + ensureMessageInfoElements(msgEl).toggle.setAttribute('aria-expanded', String(open)); + renderMessageInfo(msgEl); + schedulePersist(); +} + +function bindMessageInfoToggle(msgEl) { + if (!msgEl?.matches?.('.message.user, .message.assistant')) return; + if (!messageCreatedAt(msgEl)) return; + 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); + }); +} + +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) => { + if (!messageCreatedAt(msgEl)) return; + ensureMessageInfoElements(msgEl); + if (msgEl.classList.contains('message-info-open')) renderMessageInfo(msgEl); + }); +} + function addMessage(role, content, options = {}) { const msgEl = document.createElement('div'); msgEl.className = `message ${role}`; @@ -10176,6 +10329,10 @@ function addMessage(role, content, options = {}) { } else { messagesEl.appendChild(msgEl); } + if (role === 'user' || role === 'assistant') { + setMessageCreatedAt(msgEl, options.createdAt ?? Date.now()); + bindMessageInfoToggle(msgEl); + } if (role === 'error' && options.retryPayload && !textEl.classList.contains('cost-allowance-error')) { @@ -12038,6 +12195,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..f2b2986e1 100644 --- a/src/firefox/styles/sidepanel.css +++ b/src/firefox/styles/sidepanel.css @@ -1256,6 +1256,11 @@ body { width: 95%; } +.message.user, +.message.assistant { + cursor: pointer; +} + .message.assistant.assistant-awaiting-content { display: none; } @@ -1327,6 +1332,72 @@ 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; + 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 a03e33806..70bc557a4 100644 --- a/test/run.js +++ b/test/run.js @@ -25139,7 +25139,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 () => { @@ -47643,7 +47643,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 { @@ -49236,6 +49236,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' } }); @@ -49258,7 +49259,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`); } } @@ -49280,6 +49281,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' } }); @@ -49298,7 +49300,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( @@ -49345,7 +49347,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, { @@ -49357,6 +49359,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`, @@ -49403,6 +49406,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(''); @@ -49415,6 +49419,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`, @@ -49485,7 +49490,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({}); @@ -49512,6 +49517,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`); } }); @@ -50849,6 +50855,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' }] }, @@ -50881,6 +50888,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 { @@ -83039,4 +83047,334 @@ 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.equal(aggregateMessageCompletion(null, { + raw: { status: 'completed' }, + }, 1000).finishReason, '', `${label}: request lifecycle status is not a generation stop reason`); + assert.equal(aggregateMessageCompletion(first, { + finishReason: '', + }, 1000).finishReason, '', `${label}: an explicit empty terminal reason should clear an earlier call's stale reason`); + assert.deepEqual(buildMessageInfoPills({ + createdAt: Date.parse('2024-12-12T12:44:00Z'), + completion, + verbose: true, + locale: 'en-GB', + }), [ + { kind: 'sent', key: 'sp.message_info.sent', params: { time: '12/12/2024, 12:44 UTC' } }, + { kind: 'speed', key: 'sp.message_info.speed', params: { rate: '171.3' } }, + { kind: 'tokens', key: 'sp.message_info.tokens', params: { count: '1,295' } }, + { kind: 'duration', key: 'sp.message_info.duration', params: { seconds: '7.56' } }, + { kind: 'finish', key: 'sp.message_info.finish', params: { reason: 'stop' } }, + ], `${label}: verbose mode should add only available generation details`); + } +}); + +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 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`); + 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`); + } + } + 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), + getElementById: () => null, + }, + 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); }, + removeAttribute(name) { + delete this.attributes[name]; + if (name === 'tabindex') this.tabIndex = undefined; + if (name === 'title') this.title = ''; + }, + appendChild(child) { + child.parentNode = this; + this.children.push(child); + return child; + }, + insertBefore(child, reference) { + child.parentNode = this; + const index = this.children.indexOf(reference); + if (index < 0) this.children.push(child); + else this.children.splice(index, 0, 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();