From 8d101475dfc95fb581c6f9eee93686ff8ed441ff Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 18 Feb 2026 17:02:04 -0800 Subject: [PATCH] feat(webchat): add human-readable per-message timestamps --- docs/plans/state.json | 11 ++++++ src/gateway/ui/pages/chat.js | 76 +++++++++++++++++++++++++----------- 2 files changed, 65 insertions(+), 22 deletions(-) diff --git a/docs/plans/state.json b/docs/plans/state.json index 10f6cc3..47108bb 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5572,6 +5572,17 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/gateway/handlers/agent.test.ts src/gateway/server.test.ts + pnpm typecheck passing" + }, + "webchat-human-readable-message-timestamps": { + "status": "completed", + "date": "2026-02-19", + "updated": "2026-02-19", + "summary": "Added per-message human-readable timestamps in WebChat for live messages and loaded history, with same-day time formatting and date+time for older messages. Updated message actions to target message bodies via stable selectors so copy/edit remain correct with timestamp rows.", + "files_modified": [ + "src/gateway/ui/pages/chat.js", + "docs/plans/state.json" + ], + "test_status": "pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/gateway/ui/pages/chat.js b/src/gateway/ui/pages/chat.js index 10bb173..bd59df5 100644 --- a/src/gateway/ui/pages/chat.js +++ b/src/gateway/ui/pages/chat.js @@ -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;}