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',
+ });
+}