feat(webchat): add human-readable per-message timestamps
This commit is contained in:
@@ -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;}
|
||||
|
||||
Reference in New Issue
Block a user