feat(webchat): add human-readable per-message timestamps
This commit is contained in:
@@ -5572,6 +5572,17 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/gateway/handlers/agent.test.ts src/gateway/server.test.ts + pnpm typecheck passing"
|
"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": {
|
"overall_progress": {
|
||||||
|
|||||||
@@ -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 wrapper = document.createElement('div');
|
||||||
|
|
||||||
const roleClasses = {
|
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',
|
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.className = messageClasses[role] || messageClasses.assistant;
|
||||||
|
div.dataset.messageBody = 'true';
|
||||||
// Keep role as a data attribute for querySelector compatibility
|
// Keep role as a data attribute for querySelector compatibility
|
||||||
div.dataset.role = role;
|
div.dataset.role = role;
|
||||||
|
|
||||||
@@ -72,6 +108,7 @@ function createMessageEl(role, content) {
|
|||||||
div.textContent = content;
|
div.textContent = content;
|
||||||
}
|
}
|
||||||
wrapper.appendChild(div);
|
wrapper.appendChild(div);
|
||||||
|
wrapper.appendChild(createTimestampEl(role, timestamp));
|
||||||
|
|
||||||
// Add action buttons (copy for all, edit for user) outside the message box
|
// Add action buttons (copy for all, edit for user) outside the message box
|
||||||
if (role !== 'system') {
|
if (role !== 'system') {
|
||||||
@@ -193,9 +230,15 @@ function clearPendingAttachments() {
|
|||||||
// ── Message Action Buttons ──────────────────────────────────
|
// ── Message Action Buttons ──────────────────────────────────
|
||||||
|
|
||||||
function getMessageText(el) {
|
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 user messages, textContent is the raw text.
|
||||||
// For assistant/system messages rendered as markdown, extract plain text.
|
// For assistant/system messages rendered as markdown, extract plain text.
|
||||||
return (el.textContent || '').trim();
|
return (messageBody.textContent || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMessageActions(role) {
|
function createMessageActions(role) {
|
||||||
@@ -208,7 +251,7 @@ function createMessageActions(role) {
|
|||||||
copyBtn.title = 'Copy';
|
copyBtn.title = 'Copy';
|
||||||
copyBtn.innerHTML = COPY_ICON;
|
copyBtn.innerHTML = COPY_ICON;
|
||||||
copyBtn.addEventListener('click', () => {
|
copyBtn.addEventListener('click', () => {
|
||||||
const msgEl = bar.previousElementSibling;
|
const msgEl = bar.closest('.group')?.querySelector('[data-message-body="true"]');
|
||||||
if (!msgEl) {return;}
|
if (!msgEl) {return;}
|
||||||
const text = getMessageText(msgEl);
|
const text = getMessageText(msgEl);
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
@@ -229,7 +272,7 @@ function createMessageActions(role) {
|
|||||||
editBtn.title = 'Edit';
|
editBtn.title = 'Edit';
|
||||||
editBtn.innerHTML = EDIT_ICON;
|
editBtn.innerHTML = EDIT_ICON;
|
||||||
editBtn.addEventListener('click', () => {
|
editBtn.addEventListener('click', () => {
|
||||||
const msgEl = bar.previousElementSibling;
|
const msgEl = bar.closest('.group')?.querySelector('[data-message-body="true"]');
|
||||||
if (!msgEl) {return;}
|
if (!msgEl) {return;}
|
||||||
const text = getMessageText(msgEl);
|
const text = getMessageText(msgEl);
|
||||||
const input = _elements.input;
|
const input = _elements.input;
|
||||||
@@ -364,7 +407,7 @@ function parseSlashCommand(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showSystemMessage(content) {
|
function showSystemMessage(content) {
|
||||||
_elements.messages.appendChild(createMessageEl('system', content));
|
_elements.messages.appendChild(createMessageEl('system', content, Date.now()));
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +551,7 @@ async function loadHistory(client) {
|
|||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const role = msg.role ?? 'assistant';
|
const role = msg.role ?? 'assistant';
|
||||||
const content = msg.content ?? msg.text ?? '';
|
const content = msg.content ?? msg.text ?? '';
|
||||||
msgs.appendChild(createMessageEl(role, content));
|
msgs.appendChild(createMessageEl(role, content, msg.timestamp ?? Date.now()));
|
||||||
}
|
}
|
||||||
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
@@ -552,7 +595,7 @@ async function sendMessage(client, overrideText) {
|
|||||||
const userDisplay = hasText
|
const userDisplay = hasText
|
||||||
? rawText
|
? rawText
|
||||||
: `Sent ${_pendingAttachments.length} attachment(s)`;
|
: `Sent ${_pendingAttachments.length} attachment(s)`;
|
||||||
_elements.messages.appendChild(createMessageEl('user', userDisplay));
|
_elements.messages.appendChild(createMessageEl('user', userDisplay, Date.now()));
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
|
|
||||||
// Create placeholder for assistant response
|
// Create placeholder for assistant response
|
||||||
@@ -601,23 +644,12 @@ async function sendMessage(client, overrideText) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const done = await stream.result;
|
const done = await stream.result;
|
||||||
// Replace placeholder with actual response
|
|
||||||
placeholder.classList.remove('streaming-cursor');
|
|
||||||
const content = done?.content ?? done?.text ?? '(no response)';
|
const content = done?.content ?? done?.text ?? '(no response)';
|
||||||
placeholder.innerHTML = renderSafeMarkdown(content);
|
const assistantMessage = createMessageEl('assistant', content, Date.now());
|
||||||
|
placeholder.replaceWith(assistantMessage);
|
||||||
// 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);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
placeholder.classList.remove('streaming-cursor');
|
const errorMessage = createMessageEl('error', `Error: ${err.message}`, Date.now());
|
||||||
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.replaceWith(errorMessage);
|
||||||
placeholder.textContent = `Error: ${err.message}`;
|
|
||||||
} finally {
|
} finally {
|
||||||
_sending = false;
|
_sending = false;
|
||||||
if (_elements.sendBtn) {_elements.sendBtn.disabled = false;}
|
if (_elements.sendBtn) {_elements.sendBtn.disabled = false;}
|
||||||
|
|||||||
Reference in New Issue
Block a user