mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
3fe11518dd
commit
a70938f228
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user