diff --git a/packages/@n8n/agents/src/index.ts b/packages/@n8n/agents/src/index.ts index 9daf174a7bd..9828e64e656 100644 --- a/packages/@n8n/agents/src/index.ts +++ b/packages/@n8n/agents/src/index.ts @@ -266,6 +266,7 @@ export type { BuiltFileStore } from './types/sdk/file-store'; export type { HandlerExecutor } from './types/sdk/handler-executor'; export { filterLlmMessages, + getCreatedAt, isLlmMessage, } from './sdk/message'; export { fetchProviderCatalog } from './sdk/catalog'; diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index 0f722186b31..97eb0fd238a 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -353,6 +353,8 @@ export interface AgentPersistedMessageDto { * so history renders the same error bubble the live stream showed. */ executionError?: string; + /** ISO timestamp of when this turn was recorded. Absent on older history. */ + createdAt?: string; } export interface AgentBuilderOpenSuspension { diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index 9f28eb8343d..ba979ca84aa 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -1547,12 +1547,14 @@ describe('AgentExecutionOrchestratorService', () => { it('maps persisted execution history to chat DTOs', async () => { const { service, executionService } = makeService(); + const createdAt = new Date('2026-04-26T10:00:00.000Z'); executionService.getThreadDetail.mockResolvedValue({ thread: { id: 'thread-1' }, executions: [ { id: 'execution-1', userMessage: 'Hi', + createdAt, timeline: [{ type: 'text', content: 'Hello', timestamp: 100 }], }, ], @@ -1566,12 +1568,14 @@ describe('AgentExecutionOrchestratorService', () => { executionId: 'execution-1', role: 'user', content: [{ type: 'text', text: 'Hi' }], + createdAt: createdAt.toISOString(), }, { id: 'execution-1:assistant', executionId: 'execution-1', role: 'assistant', content: [{ type: 'text', text: 'Hello' }], + createdAt: createdAt.toISOString(), }, ]); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-message-mapper.test.ts b/packages/cli/src/modules/agents/__tests__/agent-message-mapper.test.ts index b1b5c32931e..366ec211952 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-message-mapper.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-message-mapper.test.ts @@ -4,9 +4,10 @@ import { messageToDto } from '../agent-message-mapper'; describe('agent-message-mapper — file parts', () => { it('maps fileRef file parts to reference metadata, never bytes', () => { + const createdAt = new Date('2024-01-15T10:00:00.000Z'); const message: AgentDbMessage = { id: 'm-1', - createdAt: new Date(), + createdAt, role: 'user', content: [ { type: 'text', text: 'look' }, @@ -21,6 +22,7 @@ describe('agent-message-mapper — file parts', () => { const dto = messageToDto(message); + expect(dto?.createdAt).toBe(createdAt.toISOString()); expect(dto?.content[1]).toEqual({ type: 'file', fileId: 'att-1', @@ -31,15 +33,38 @@ describe('agent-message-mapper — file parts', () => { }); it('exposes no file fields for file parts without a fileRef', () => { + const createdAt = new Date('2024-01-15T10:00:00.000Z'); const message: AgentDbMessage = { id: 'm-1', - createdAt: new Date(), + createdAt, role: 'assistant', content: [{ type: 'file', mediaType: 'image/png', data: new Uint8Array([1]) }], }; const dto = messageToDto(message); + expect(dto?.createdAt).toBe(createdAt.toISOString()); expect(dto?.content[0]).toEqual({ type: 'file' }); }); }); + +describe('agent-message-mapper — createdAt', () => { + // Checkpoint state is JSON-parsed, so a suspended turn reaches the mapper + // with an ISO string, or with no createdAt at all on older states. + function message(createdAt: unknown): AgentDbMessage { + return { + id: 'm-1', + role: 'user', + content: [{ type: 'text', text: 'hi' }], + createdAt, + } as unknown as AgentDbMessage; + } + + it('maps an ISO string through, and omits createdAt when it is unusable', () => { + expect(messageToDto(message('2024-01-15T10:00:00.000Z'))?.createdAt).toBe( + '2024-01-15T10:00:00.000Z', + ); + expect(messageToDto(message(undefined))?.createdAt).toBeUndefined(); + expect(messageToDto(message('not a date'))?.createdAt).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts index b502a82f619..2709124b76a 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts @@ -4,11 +4,14 @@ import { executionsToMessagesDto, } from '../utils/execution-to-message-mapper'; +const FIXED_CREATED_AT = new Date('2024-01-15T10:00:00.000Z'); + function execution(overrides: Partial = {}): AgentExecution { return { id: 'execution-1', userMessage: 'Hello', timeline: null, + createdAt: FIXED_CREATED_AT, ...overrides, } as unknown as AgentExecution; } @@ -41,6 +44,7 @@ describe('execution-to-message-mapper', () => { content: [], executionStatus: status, backgroundTaskSignal: signal, + createdAt: FIXED_CREATED_AT.toISOString(), }, ]); }, @@ -189,6 +193,7 @@ describe('execution-to-message-mapper', () => { role: 'user', content: [{ type: 'text', text: 'Hello' }], executionId: 'execution-1', + createdAt: FIXED_CREATED_AT.toISOString(), }, { id: 'execution-1:assistant', @@ -208,6 +213,7 @@ describe('execution-to-message-mapper', () => { { type: 'text', text: 'Done.' }, ], executionId: 'execution-1', + createdAt: FIXED_CREATED_AT.toISOString(), }, ]); }); @@ -276,6 +282,7 @@ describe('execution-to-message-mapper', () => { role: 'user', content: [{ type: 'text', text: 'Hello' }], executionId: 'execution-1', + createdAt: FIXED_CREATED_AT.toISOString(), }, { id: 'execution-1:assistant', @@ -293,6 +300,7 @@ describe('execution-to-message-mapper', () => { }, ], executionId: 'execution-1', + createdAt: FIXED_CREATED_AT.toISOString(), }, ]); }); @@ -318,6 +326,7 @@ describe('execution-to-message-mapper', () => { }, ], executionId: 'execution-1', + createdAt: FIXED_CREATED_AT.toISOString(), }); }); @@ -360,6 +369,7 @@ describe('execution-to-message-mapper', () => { executionId: 'execution-1', executionStatus: 'error', executionError: 'Model request failed', + createdAt: FIXED_CREATED_AT.toISOString(), }); }); @@ -441,6 +451,7 @@ describe('execution-to-message-mapper', () => { role: 'user', content: [{ type: 'text', text: 'Show me an action' }], executionId: 'execution-suspended', + createdAt: FIXED_CREATED_AT.toISOString(), }, { id: 'execution-suspended:assistant', @@ -469,12 +480,14 @@ describe('execution-to-message-mapper', () => { }, ], executionId: 'execution-suspended', + createdAt: FIXED_CREATED_AT.toISOString(), }, { id: 'execution-resumed:assistant', role: 'assistant', content: [{ type: 'text', text: 'Approved.' }], executionId: 'execution-resumed', + createdAt: FIXED_CREATED_AT.toISOString(), }, ]); }); diff --git a/packages/cli/src/modules/agents/agent-message-mapper.ts b/packages/cli/src/modules/agents/agent-message-mapper.ts index d5285e9d356..6a4eb0aae46 100644 --- a/packages/cli/src/modules/agents/agent-message-mapper.ts +++ b/packages/cli/src/modules/agents/agent-message-mapper.ts @@ -1,4 +1,4 @@ -import type { AgentDbMessage, MessageContent } from '@n8n/agents'; +import { getCreatedAt, type AgentDbMessage, type MessageContent } from '@n8n/agents'; import type { AgentPersistedMessageContentPart, AgentPersistedMessageDto } from '@n8n/api-types'; export function contentPartToDto(part: MessageContent): AgentPersistedMessageContentPart { @@ -25,10 +25,16 @@ export function contentPartToDto(part: MessageContent): AgentPersistedMessageCon export function messageToDto(msg: AgentDbMessage): AgentPersistedMessageDto | null { if (!('role' in msg) || !Array.isArray(msg.content)) return null; + // Checkpoint state is JSON-parsed, so `createdAt` reaches us as an ISO string + // and can be absent in older states, although the type says `Date`. + // ponytail: normalise here, because the parse boundary keeps the lie. The + // deeper fix is a revive step in `parseSuspendedState` (n8n-checkpoint-storage). + const createdAt = getCreatedAt(msg); return { id: msg.id, role: msg.role, content: msg.content.map(contentPartToDto), + ...(createdAt ? { createdAt: createdAt.toISOString() } : {}), }; } diff --git a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts index 8a117b7d75c..66a2da58a40 100644 --- a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts +++ b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts @@ -6,7 +6,7 @@ import type { TimelineEvent } from '../execution-recorder'; type ExecutionTranscript = Pick< AgentExecution, - 'id' | 'userMessage' | 'author' | 'timeline' | 'attachments' | 'status' | 'error' + 'id' | 'userMessage' | 'author' | 'timeline' | 'attachments' | 'status' | 'error' | 'createdAt' >; type ToolCallTimelineEvent = Extract; @@ -147,6 +147,9 @@ export function executionToMessagesDto(execution: ExecutionTranscript): AgentPer // make consumers parse it back out of `id`. const userContent: AgentPersistedMessageContentPart[] = []; const userText = execution.userMessage === null ? null : textPart(execution.userMessage); + // One execution row is one turn, so both of its messages share its timestamp. + const createdAt = execution.createdAt.toISOString(); + if (userText) userContent.push(userText); for (const attachment of execution.attachments ?? []) { userContent.push({ @@ -164,6 +167,7 @@ export function executionToMessagesDto(execution: ExecutionTranscript): AgentPer content: userContent, ...(execution.author ? { author: execution.author } : {}), executionId: execution.id, + createdAt, }); } @@ -189,6 +193,7 @@ export function executionToMessagesDto(execution: ExecutionTranscript): AgentPer executionId: execution.id, ...(execution.status ? { executionStatus: execution.status } : {}), ...(executionError !== undefined ? { executionError } : {}), + createdAt, }); } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 9984ab8936c..f2bfe306757 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7737,6 +7737,9 @@ "agents.relativeTime.minutesAgo": "{count}m ago", "agents.relativeTime.hoursAgo": "{count}h ago", "agents.relativeTime.yesterday": "Yesterday", + "agents.chat.timestamp.today": "Today at {time}", + "agents.chat.timestamp.yesterday": "Yesterday at {time}", + "agents.chat.timestamp.date": "{date} at {time}", "agents.sort.lastUpdated": "Sort by last updated", "agents.sort.lastCreated": "Sort by last created", "agents.sort.nameAsc": "Sort by name (A-Z)", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts index d250b409eab..626c6f4c0e1 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts @@ -886,4 +886,85 @@ describe('AgentChatMessageList', () => { await flushPromises(); expect(copySpy).toHaveBeenCalledWith('First reply\n\nSecond reply'); }); + + describe('timestamp dividers', () => { + const T0 = Date.parse('2026-04-26T10:00:00Z'); + + it('renders a divider above the first user message', () => { + const wrapper = mount(AgentChatMessageList, { + props: { + messages: [ + { id: 'user-1', role: 'user', content: 'Hi', status: 'success', createdAt: T0 }, + ] satisfies ChatMessage[], + messagingState: 'idle', + }, + }); + + expect(wrapper.findAll('[data-testid="agent-chat-timestamp-divider"]')).toHaveLength(1); + }); + + it('renders a divider again only once the gap exceeds the window', () => { + const wrapper = mount(AgentChatMessageList, { + props: { + messages: [ + { id: 'user-1', role: 'user', content: 'Hi', status: 'success', createdAt: T0 }, + { + id: 'user-2', + role: 'user', + content: 'Again', + status: 'success', + createdAt: T0 + 5 * 60_000, + }, + { + id: 'user-3', + role: 'user', + content: 'Later', + status: 'success', + createdAt: T0 + 2 * 60 * 60_000, + }, + ] satisfies ChatMessage[], + messagingState: 'idle', + }, + }); + + expect(wrapper.findAll('[data-testid="agent-chat-timestamp-divider"]')).toHaveLength(2); + }); + + it('renders a divider across midnight even inside the window', () => { + // Local time on purpose: the rule is about the viewer's calendar day. + const lateNight = new Date('2026-04-26T23:40:00').getTime(); + const afterMidnight = new Date('2026-04-27T00:20:00').getTime(); + const wrapper = mount(AgentChatMessageList, { + props: { + messages: [ + { id: 'user-1', role: 'user', content: 'Hi', status: 'success', createdAt: lateNight }, + { + id: 'user-2', + role: 'user', + content: 'Again', + status: 'success', + createdAt: afterMidnight, + }, + ] satisfies ChatMessage[], + messagingState: 'idle', + }, + }); + + expect(wrapper.findAll('[data-testid="agent-chat-timestamp-divider"]')).toHaveLength(2); + }); + + it('renders no divider at all when createdAt is absent', () => { + const wrapper = mount(AgentChatMessageList, { + props: { + messages: [ + { id: 'user-1', role: 'user', content: 'Hi', status: 'success' }, + { id: 'user-2', role: 'user', content: 'Again', status: 'success' }, + ] satisfies ChatMessage[], + messagingState: 'idle', + }, + }); + + expect(wrapper.findAll('[data-testid="agent-chat-timestamp-divider"]')).toHaveLength(0); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/relative-time.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/relative-time.test.ts index b5d9592b601..6689a992221 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/relative-time.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/relative-time.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatRelativeTimestamp } from '../utils/relative-time'; +import { formatChatDividerTimestamp, formatRelativeTimestamp } from '../utils/relative-time'; const i18n = { justNow: 'just now', @@ -50,3 +50,37 @@ describe('formatRelativeTimestamp', () => { expect(formatRelativeTimestamp(future, i18n, NOW)).toBe('just now'); }); }); + +const dividerI18n = { + today: (time: string) => `Today at ${time}`, + yesterday: (time: string) => `Yesterday at ${time}`, + date: (date: string, time: string) => `${date} at ${time}`, +}; + +const at = (d: Date) => d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + +describe('formatChatDividerTimestamp', () => { + it('returns "Today at {time}" for a timestamp on the same local day', () => { + const today = new Date('2026-04-26T10:20:00'); + expect(formatChatDividerTimestamp(today, dividerI18n, NOW)).toBe(`Today at ${at(today)}`); + }); + + it('returns "Yesterday at {time}" for a timestamp on the previous local day', () => { + const yesterday = new Date('2026-04-25T10:20:00'); + expect(formatChatDividerTimestamp(yesterday, dividerI18n, NOW)).toBe( + `Yesterday at ${at(yesterday)}`, + ); + }); + + it('returns "{weekday}, {month} {day} at {time}" for an older timestamp', () => { + const older = new Date('2026-04-20T10:20:00'); + const expectedDate = older.toLocaleDateString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + expect(formatChatDividerTimestamp(older, dividerI18n, NOW)).toBe( + `${expectedDate} at ${at(older)}`, + ); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue index 0bd9f26884f..fca4f35f657 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue @@ -5,6 +5,7 @@ import { N8N_CHAT_ACTION_TOOL_NAME } from '@n8n/api-types'; import { isAwaitingCard } from '@/features/ai/shared/agentsChat/n8nChatInteraction'; import { useI18n } from '@n8n/i18n'; import { useSessionStorage } from '@vueuse/core'; +import { TIME } from '@/app/constants/durations'; import { buildDisplayGroups, isAssistantGroup, @@ -32,6 +33,7 @@ import AgentTypingIndicator from './AgentTypingIndicator.vue'; import InteractiveCard from './interactive/InteractiveCard.vue'; import type { AgentFixWithAssistantFailure, AgentSendToAssistantEvent } from '../types'; import { looksLikeAgentChangeRequest } from '../utils/agent-change-request'; +import { isSameLocalDay, useChatDividerTimestamp } from '../utils/relative-time'; import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants'; const props = defineProps<{ @@ -148,6 +150,36 @@ const scrollRef = useTemplateRef('scrollRef'); const displayGroups = computed(() => buildDisplayGroups(props.messages)); +const formatChatDividerTimestamp = useChatDividerTimestamp(); + +/** + * Divider label by group id: the first user message gets one, and so does any + * later user message whose gap since the previous one exceeds the window above. + * Messages without a `createdAt` (older history) are skipped and never become + * the "previous" reference. + */ +const dividerLabels = computed(() => { + const labels = new Map(); + let previousUserCreatedAt: number | undefined; + for (const group of displayGroups.value) { + if (group.kind !== 'message' || group.message.role !== 'user') continue; + const createdAt = group.message.createdAt; + if (createdAt === undefined) continue; + // A new local day always earns a divider, even inside the window: without + // it a chat that crosses midnight files today's messages under a + // "Yesterday at ..." heading. + if ( + previousUserCreatedAt === undefined || + createdAt - previousUserCreatedAt > TIME.HOUR || + !isSameLocalDay(new Date(createdAt), new Date(previousUserCreatedAt)) + ) { + labels.set(group.id, formatChatDividerTimestamp(createdAt)); + } + previousUserCreatedAt = createdAt; + } + return labels; +}); + /** * Dismissing the note silences it for the rest of this preview chat, so a user * who does not want the hand-off is not asked again on every request in the @@ -412,6 +444,13 @@ watch(