feat(webchat): add human-readable per-message timestamps

This commit is contained in:
William Valentin
2026-02-18 17:02:04 -08:00
parent a13aa3113e
commit 8d101475df
2 changed files with 65 additions and 22 deletions
+54 -22
View File
@@ -42,7 +42,42 @@ function highlightCode() {
}
}
function createMessageEl(role, content) {
function formatMessageTimestamp(timestamp) {
const date = new Date(typeof timestamp === 'number' ? timestamp : Date.now());
if (Number.isNaN(date.getTime())) {
return '';
}
const now = new Date();
const isSameDay = now.getFullYear() === date.getFullYear()
&& now.getMonth() === date.getMonth()
&& now.getDate() === date.getDate();
const timeText = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
}).format(date);
if (isSameDay) {
return timeText;
}
const dateText = new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
}).format(date);
return `${dateText}, ${timeText}`;
}
function createTimestampEl(role, timestamp) {
const ts = document.createElement('div');
const alignClass = role === 'user' ? 'self-end text-right' : 'self-start text-left';
ts.className = `px-1 text-[11px] leading-none text-zinc-600 select-none ${alignClass}`;
ts.textContent = formatMessageTimestamp(timestamp);
return ts;
}
function createMessageEl(role, content, timestamp = Date.now()) {
const wrapper = document.createElement('div');
const roleClasses = {
@@ -62,6 +97,7 @@ function createMessageEl(role, content) {
error: 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-red-500/15 border border-red-500/30 text-zinc-50',
};
div.className = messageClasses[role] || messageClasses.assistant;
div.dataset.messageBody = 'true';
// Keep role as a data attribute for querySelector compatibility
div.dataset.role = role;
@@ -72,6 +108,7 @@ function createMessageEl(role, content) {
div.textContent = content;
}
wrapper.appendChild(div);
wrapper.appendChild(createTimestampEl(role, timestamp));
// Add action buttons (copy for all, edit for user) outside the message box
if (role !== 'system') {
@@ -193,9 +230,15 @@ function clearPendingAttachments() {
// ── Message Action Buttons ──────────────────────────────────
function getMessageText(el) {
const messageBody = el?.dataset?.messageBody === 'true'
? el
: el?.closest?.('.group')?.querySelector?.('[data-message-body="true"]');
if (!messageBody) {
return '';
}
// For user messages, textContent is the raw text.
// For assistant/system messages rendered as markdown, extract plain text.
return (el.textContent || '').trim();
return (messageBody.textContent || '').trim();
}
function createMessageActions(role) {
@@ -208,7 +251,7 @@ function createMessageActions(role) {
copyBtn.title = 'Copy';
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', () => {
const msgEl = bar.previousElementSibling;
const msgEl = bar.closest('.group')?.querySelector('[data-message-body="true"]');
if (!msgEl) {return;}
const text = getMessageText(msgEl);
navigator.clipboard.writeText(text).then(() => {
@@ -229,7 +272,7 @@ function createMessageActions(role) {
editBtn.title = 'Edit';
editBtn.innerHTML = EDIT_ICON;
editBtn.addEventListener('click', () => {
const msgEl = bar.previousElementSibling;
const msgEl = bar.closest('.group')?.querySelector('[data-message-body="true"]');
if (!msgEl) {return;}
const text = getMessageText(msgEl);
const input = _elements.input;
@@ -364,7 +407,7 @@ function parseSlashCommand(text) {
}
function showSystemMessage(content) {
_elements.messages.appendChild(createMessageEl('system', content));
_elements.messages.appendChild(createMessageEl('system', content, Date.now()));
scrollToBottom();
}
@@ -508,7 +551,7 @@ async function loadHistory(client) {
for (const msg of messages) {
const role = msg.role ?? 'assistant';
const content = msg.content ?? msg.text ?? '';
msgs.appendChild(createMessageEl(role, content));
msgs.appendChild(createMessageEl(role, content, msg.timestamp ?? Date.now()));
}
scrollToBottom();
@@ -552,7 +595,7 @@ async function sendMessage(client, overrideText) {
const userDisplay = hasText
? rawText
: `Sent ${_pendingAttachments.length} attachment(s)`;
_elements.messages.appendChild(createMessageEl('user', userDisplay));
_elements.messages.appendChild(createMessageEl('user', userDisplay, Date.now()));
scrollToBottom();
// Create placeholder for assistant response
@@ -601,23 +644,12 @@ async function sendMessage(client, overrideText) {
});
const done = await stream.result;
// Replace placeholder with actual response
placeholder.classList.remove('streaming-cursor');
const content = done?.content ?? done?.text ?? '(no response)';
placeholder.innerHTML = renderSafeMarkdown(content);
// Wrap placeholder in a proper message wrapper for action buttons
const wrapper = document.createElement('div');
wrapper.className = 'flex flex-col gap-1.5 max-w-[85%] md:max-w-[75%] self-start group';
placeholder.parentNode.insertBefore(wrapper, placeholder);
wrapper.appendChild(placeholder);
wrapper.appendChild(createMessageActions('assistant'));
setTimeout(highlightCode, 0);
const assistantMessage = createMessageEl('assistant', content, Date.now());
placeholder.replaceWith(assistantMessage);
} catch (err) {
placeholder.classList.remove('streaming-cursor');
placeholder.className = 'rounded-lg px-3.5 py-2.5 text-sm leading-relaxed break-words whitespace-pre-wrap bg-red-500/15 border border-red-500/30 text-zinc-50';
placeholder.textContent = `Error: ${err.message}`;
const errorMessage = createMessageEl('error', `Error: ${err.message}`, Date.now());
placeholder.replaceWith(errorMessage);
} finally {
_sending = false;
if (_elements.sendBtn) {_elements.sendBtn.disabled = false;}