diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts
index ecf47134..6918fde4 100644
--- a/web/src/chat/presentation.test.ts
+++ b/web/src/chat/presentation.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { getEventPresentation, formatResetTime } from './presentation'
+import { getEventPresentation, formatMessageTimestamp, formatResetTime } from './presentation'
describe('getEventPresentation — limit-warning', () => {
it('formats five_hour warning', () => {
@@ -139,3 +139,18 @@ describe('formatResetTime', () => {
expect(result).toBeTruthy()
})
})
+
+describe('formatMessageTimestamp', () => {
+ it('formats today without requiring a date prefix', () => {
+ const now = new Date(2026, 4, 22, 14, 30)
+ const result = formatMessageTimestamp(new Date(2026, 4, 22, 9, 5), now)
+ expect(result).toBeTruthy()
+ expect(result).not.toContain('2026')
+ })
+
+ it('includes a year for messages outside the current year', () => {
+ const now = new Date(2026, 4, 22, 14, 30)
+ const result = formatMessageTimestamp(new Date(2025, 11, 31, 23, 59), now)
+ expect(result).toContain('2025')
+ })
+})
diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts
index 6058c82d..676c6bcc 100644
--- a/web/src/chat/presentation.ts
+++ b/web/src/chat/presentation.ts
@@ -26,6 +26,34 @@ export function formatResetTime(value: number): string {
return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
}
+export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
+ const sameDay = date.getFullYear() === now.getFullYear()
+ && date.getMonth() === now.getMonth()
+ && date.getDate() === now.getDate()
+
+ if (sameDay) {
+ return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })
+ }
+
+ const sameYear = date.getFullYear() === now.getFullYear()
+ if (sameYear) {
+ return date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
+ }
+
+ return date.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
+}
+
+export function formatMessageTimestampTitle(date: Date): string {
+ return date.toLocaleString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ second: '2-digit'
+ })
+}
+
// Known types: five_hour → "5-hour", seven_day → "7-day".
// Unknown types use underscore-to-space fallback (e.g. thirty_day → "thirty day").
function formatLimitType(limitType: string | undefined): string {
diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx
index ce922111..b04bc998 100644
--- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx
+++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx
@@ -12,6 +12,7 @@ import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata'
import { isNestedInteractiveEvent } from '@/components/AssistantChat/messages/metadataToggle'
import { CodexReviewCard } from '@/components/AssistantChat/messages/CodexReviewCard'
+import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
const TOOL_COMPONENTS = {
Fallback: HappyToolMessage
@@ -85,16 +86,19 @@ export function HappyAssistantMessage() {
className="scroll-mt-4 px-1 min-w-0 max-w-full overflow-x-hidden"
>