feat(editor): Show timestamp dividers in the agent preview chat (#38962)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Schroth 2026-09-18 10:54:17 +00:00 committed by GitHub
parent 3fe11518dd
commit a70938f228
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 279 additions and 6 deletions

View File

@ -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';

View File

@ -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 {

View File

@ -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(),
},
]);
});

View File

@ -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();
});
});

View File

@ -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> = {}): 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(),
},
]);
});

View File

@ -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() } : {}),
};
}

View File

@ -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<TimelineEvent, { type: 'tool-call' }>;
@ -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,
});
}

View File

@ -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)",

View File

@ -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);
});
});
});

View File

@ -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)}`,
);
});
});

View File

@ -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<HTMLDivElement>('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<string, string>();
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(
<template>
<div ref="scrollRef" :class="$style.messages" @scroll.passive="onScroll">
<template v-for="group in displayGroups" :key="group.id">
<div
v-if="dividerLabels.has(group.id)"
:class="$style.timestampDivider"
data-testid="agent-chat-timestamp-divider"
>
<N8nText size="small" color="text-light">{{ dividerLabels.get(group.id) }}</N8nText>
</div>
<div v-if="group.kind === 'backgroundJobSignal'" :class="[$style.message, $style.assistant]">
<AgentChatBackgroundJobSignal :class="$style.content" :signal="group.signal" />
</div>
@ -690,6 +729,11 @@ watch(
padding-top: var(--spacing--4xs);
}
.timestampDivider {
align-self: center;
padding: var(--spacing--sm) 0 var(--spacing--xs);
}
.content {
display: flex;
flex-direction: column;

View File

@ -969,6 +969,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
role: 'user',
content: text,
status: 'success',
createdAt: Date.now(),
});
}
@ -1029,6 +1030,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
role: 'user',
content: trimmed,
status: 'success',
createdAt: Date.now(),
...(files?.length && {
attachments: files.map((file) => ({
fileName: file.name,

View File

@ -51,7 +51,7 @@ export function formatRelativeTimestamp(
return past.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
function isSameLocalDay(a: Date, b: Date): boolean {
export function isSameLocalDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
@ -65,6 +65,52 @@ function isYesterdayLocal(past: Date, now: Date): boolean {
return isSameLocalDay(past, yesterday);
}
interface ChatDividerI18n {
today: (time: string) => string;
yesterday: (time: string) => string;
date: (date: string, time: string) => string;
}
/**
* Label for a ChatGPT-style timestamp divider above a chat message:
*
* - same local day → "Today at {time}"
* - previous local day → "Yesterday at {time}"
* - older → "{weekday}, {month} {day} at {time}"
*
* Time and date formatting are left to the viewer's locale (12h/24h, date order).
*/
export function formatChatDividerTimestamp(
date: Date | string | number,
i18n: ChatDividerI18n,
now: Date = new Date(),
): string {
const past = new Date(date);
const time = past.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (isSameLocalDay(past, now)) return i18n.today(time);
if (isYesterdayLocal(past, now)) return i18n.yesterday(time);
const dateLabel = past.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
return i18n.date(dateLabel, time);
}
export function useChatDividerTimestamp() {
const i18n = useI18n();
const strings: ChatDividerI18n = {
today: (time) => i18n.baseText('agents.chat.timestamp.today', { interpolate: { time } }),
yesterday: (time) =>
i18n.baseText('agents.chat.timestamp.yesterday', { interpolate: { time } }),
date: (date, time) =>
i18n.baseText('agents.chat.timestamp.date', { interpolate: { date, time } }),
};
return (date: Date | string | number) => formatChatDividerTimestamp(date, strings);
}
export function useRelativeTimestamp() {
const i18n = useI18n();
const strings: RelativeI18n = {

View File

@ -320,6 +320,10 @@ export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatM
}
}
// A malformed wire timestamp must not reach the transcript as NaN: it would
// silence every later timestamp divider in the chat.
const createdAt = msg.createdAt ? Date.parse(msg.createdAt) : NaN;
const chatMessage: ChatMessage = {
id: msg.id ?? crypto.randomUUID(),
role,
@ -335,6 +339,7 @@ export function convertDbMessages(dbMessages: AgentPersistedMessageDto[]): ChatM
...(role === 'assistant' && msg.backgroundTaskSignal
? { backgroundJobSignal: msg.backgroundTaskSignal }
: {}),
...(Number.isFinite(createdAt) && { createdAt }),
};
setMessageInteractives(chatMessage, interactives);
result.push(chatMessage);

View File

@ -143,6 +143,8 @@ export interface AgentsChatMessage {
attachments?: ChatMessageAttachment[];
/** Persisted agent execution id for this turn (history parse or live SSE `done`). */
executionId?: string;
/** Epoch ms when this turn was sent. Drives the chat's timestamp dividers. */
createdAt?: number;
}
export type ChatMessage = AgentsChatMessage;