From 9de26253c51b348fa99184edeb1bf27703c615b0 Mon Sep 17 00:00:00 2001 From: Arsen Muk Date: Thu, 13 Aug 2026 07:51:36 +0100 Subject: [PATCH] Chat: show a datetime at the bottom-right of each message Visualize the per-message created_at (already persisted server-side and preserved by hydrateMessage on history load) as a compact local datetime in the bottom-right corner of every user prompt and assistant reply. The label reuses the collapsed session-group counter styling (text-[10px] text-text-faint/60 tabular-nums); full precision on hover. Live (just-sent / just-streamed) messages are created client-side without a server timestamp, so stamp them with the client clock at creation, so the current turn shows a time immediately without needing a reload. Co-Authored-By: Claude Opus 4.8 --- web/src/components/Chat/AssistantMessage.tsx | 9 +++++++++ web/src/components/Chat/UserMessage.tsx | 9 +++++++++ web/src/stores/chatStore.ts | 2 +- web/src/stores/handlers/sessionHandlers.ts | 5 +++-- web/src/stores/handlers/streamingHandlers.ts | 3 ++- web/src/utils/messageTime.ts | 16 ++++++++++++++++ 6 files changed, 40 insertions(+), 4 deletions(-) create mode 100644 web/src/utils/messageTime.ts diff --git a/web/src/components/Chat/AssistantMessage.tsx b/web/src/components/Chat/AssistantMessage.tsx index 19fe923b..8467506d 100644 --- a/web/src/components/Chat/AssistantMessage.tsx +++ b/web/src/components/Chat/AssistantMessage.tsx @@ -1,6 +1,7 @@ import { Repeat } from 'lucide-react'; import type { ChatMessage } from '../../types/chat'; import { BlockRenderer } from './BlockRenderer'; +import { formatMessageTime } from '../../utils/messageTime'; export function AssistantMessage({ message }: { message: ChatMessage }) { // Review-loop milestones are controller-written system entries, not the @@ -30,6 +31,14 @@ export function AssistantMessage({ message }: { message: ChatMessage }) { + {message.created_at && ( +
+ {formatMessageTime(message.created_at)} +
+ )} ); diff --git a/web/src/components/Chat/UserMessage.tsx b/web/src/components/Chat/UserMessage.tsx index 80079431..f354c50d 100644 --- a/web/src/components/Chat/UserMessage.tsx +++ b/web/src/components/Chat/UserMessage.tsx @@ -1,6 +1,7 @@ import type { ChatMessage, ImageBlockData, FileBlockData } from '../../types/chat'; import { Download, FileText } from 'lucide-react'; import { getToken } from '../../api/client'; +import { formatMessageTime } from '../../utils/messageTime'; function authUrl(url: string): string { const token = getToken(); @@ -63,6 +64,14 @@ export function UserMessage({ message }: { message: ChatMessage }) { )} + {message.created_at && ( +
+ {formatMessageTime(message.created_at)} +
+ )} ); diff --git a/web/src/stores/chatStore.ts b/web/src/stores/chatStore.ts index 0614f984..3e7ba4d0 100644 --- a/web/src/stores/chatStore.ts +++ b/web/src/stores/chatStore.ts @@ -885,7 +885,7 @@ export const useChatStore = create((set, get) => ({ // socket isn't open, send() returns 'queued' (will flush on reconnect) // or 'dropped' (revert below). set((state) => ({ - messages: [...state.messages, { role: 'user' as const, blocks }], + messages: [...state.messages, { role: 'user' as const, blocks, created_at: new Date().toISOString() }], streamingBlocks: [], isStreaming: true, agentStatus: { state: 'thinking' as const }, diff --git a/web/src/stores/handlers/sessionHandlers.ts b/web/src/stores/handlers/sessionHandlers.ts index fe1b131a..0e957eeb 100644 --- a/web/src/stores/handlers/sessionHandlers.ts +++ b/web/src/stores/handlers/sessionHandlers.ts @@ -266,7 +266,7 @@ export function handleSessionRunning( if (finalBlocks.length > 0) { updates.messages = [ ...s.messages, - { role: 'assistant' as const, blocks: finalBlocks }, + { role: 'assistant' as const, blocks: finalBlocks, created_at: new Date().toISOString() }, ]; } updates.streamingBlocks = []; @@ -309,6 +309,7 @@ export function handleAnswerInjected( messages: [...s.messages, { role: 'user' as const, blocks: [{ type: 'text' as const, content: msg.content }], + created_at: new Date().toISOString(), }], })); } @@ -323,6 +324,6 @@ export function handleUserMessage( // bubble live. The sender is excluded server-side, so this never duplicates // its own optimistic message. hydrateMessage rebuilds any image/file blocks. if (msg.session_id !== get().activeSession) return; - const hydrated = hydrateMessage({ role: 'user', content: msg.content, blocks: msg.blocks ?? undefined }); + const hydrated = hydrateMessage({ role: 'user', content: msg.content, blocks: msg.blocks ?? undefined, created_at: new Date().toISOString() }); set(s => ({ messages: [...s.messages, hydrated] })); } diff --git a/web/src/stores/handlers/streamingHandlers.ts b/web/src/stores/handlers/streamingHandlers.ts index d18a08e2..d6bd2c2a 100644 --- a/web/src/stores/handlers/streamingHandlers.ts +++ b/web/src/stores/handlers/streamingHandlers.ts @@ -463,7 +463,7 @@ export function handleDone( : b ); set((s) => ({ - messages: [...s.messages, { role: 'assistant' as const, blocks: finalBlocks }], + messages: [...s.messages, { role: 'assistant' as const, blocks: finalBlocks, created_at: new Date().toISOString() }], streamingBlocks: [], isStreaming: false, ...doneUpdate, @@ -496,6 +496,7 @@ export function handleStopped( blocks: finalBlocks.length > 0 ? finalBlocks : [{ type: 'text', content: '*[Stopped by user]*' }], + created_at: new Date().toISOString(), }], streamingBlocks: [], isStreaming: false, diff --git a/web/src/utils/messageTime.ts b/web/src/utils/messageTime.ts new file mode 100644 index 00000000..d1a62cdc --- /dev/null +++ b/web/src/utils/messageTime.ts @@ -0,0 +1,16 @@ +/** + * Compact local datetime for a chat message's `created_at`, + * e.g. "Aug 13, 2:41 AM" (locale-aware; 24h where the locale uses it). + * Returns '' for missing/invalid input so callers can guard on falsy. + */ +export function formatMessageTime(input?: string): string { + if (!input) return ''; + const date = new Date(input); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +}