mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Add visible conversation timestamps (#120)
* Add visible conversation timestamps Generated-By: looper 0.2.7 (runner=worker, agent=codex) * Fix assistant message timestamps Generated-By: looper 0.2.7 (runner=fixer, agent=codex)
This commit is contained in:
parent
85032f530c
commit
0bafc73d24
9 changed files with 263 additions and 32 deletions
|
|
@ -588,7 +588,7 @@ export function listMessages(db, conversationId) {
|
|||
events_json AS eventsJson,
|
||||
attachments_json AS attachmentsJson,
|
||||
produced_files_json AS producedFilesJson,
|
||||
started_at AS startedAt, ended_at AS endedAt,
|
||||
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
|
||||
position
|
||||
FROM messages
|
||||
WHERE conversation_id = ?
|
||||
|
|
@ -675,7 +675,7 @@ export function upsertMessage(db, conversationId, m) {
|
|||
events_json AS eventsJson,
|
||||
attachments_json AS attachmentsJson,
|
||||
produced_files_json AS producedFilesJson,
|
||||
started_at AS startedAt, ended_at AS endedAt,
|
||||
created_at AS createdAt, started_at AS startedAt, ended_at AS endedAt,
|
||||
position
|
||||
FROM messages WHERE id = ?`,
|
||||
)
|
||||
|
|
@ -700,6 +700,7 @@ function normalizeMessage(row) {
|
|||
events: parseJsonOrUndef(row.eventsJson),
|
||||
attachments: parseJsonOrUndef(row.attachmentsJson),
|
||||
producedFiles: parseJsonOrUndef(row.producedFilesJson),
|
||||
createdAt: row.createdAt ?? undefined,
|
||||
startedAt: row.startedAt ?? undefined,
|
||||
endedAt: row.endedAt ?? undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useT } from '../i18n';
|
|||
import { unfinishedTodosFromEvents, type TodoItem } from '../runtime/todos';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { agentDisplayName } from '../utils/agentLabels';
|
||||
import { exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
|
||||
import type { AgentEvent, ChatMessage, ProjectFile } from '../types';
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
|
@ -70,7 +71,10 @@ export function AssistantMessage({
|
|||
|
||||
return (
|
||||
<div className="msg assistant">
|
||||
<div className="role">{roleLabel}</div>
|
||||
<div className="role">
|
||||
<span>{roleLabel}</span>
|
||||
<MessageTimestamp message={message} t={t} />
|
||||
</div>
|
||||
<div className="assistant-flow">
|
||||
{blocks.length === 0 && streaming ? (
|
||||
<WaitingPill startedAt={message.startedAt} latestStatus={latestStatusLabel(events)} />
|
||||
|
|
@ -135,6 +139,16 @@ export function AssistantMessage({
|
|||
);
|
||||
}
|
||||
|
||||
function MessageTimestamp({ message, t }: { message: ChatMessage; t: TranslateFn }) {
|
||||
const ts = messageTime(message);
|
||||
if (!ts) return null;
|
||||
return (
|
||||
<time className="msg-time" dateTime={new Date(ts).toISOString()} title={exactDateTime(ts)}>
|
||||
{relativeTimeLong(ts, t)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function assistantRoleLabel(message: ChatMessage, t: TranslateFn): string {
|
||||
const fromMetadata = agentDisplayName(message.agentId, message.agentName);
|
||||
if (fromMetadata) return fromMetadata;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Fragment, useEffect, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { projectRawUrl } from '../providers/registry';
|
||||
import type { TodoItem } from '../runtime/todos';
|
||||
import type { ChatAttachment, ChatMessage, Conversation, ProjectFile } from '../types';
|
||||
import { dayKey, dayLabel, exactDateTime, messageTime, relativeTimeLong } from '../utils/chatTime';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { ChatComposer, type ChatComposerHandle } from './ChatComposer';
|
||||
import { Icon } from './Icon';
|
||||
|
|
@ -352,36 +353,40 @@ export function ChatPane({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{messages.map((m) => {
|
||||
{messages.map((m, i) => {
|
||||
const showDaySeparator = shouldShowDaySeparator(messages[i - 1], m);
|
||||
const messageStreaming =
|
||||
m.role === 'assistant' &&
|
||||
((streaming && m.id === lastAssistantId) || isActiveRunStatus(m.runStatus));
|
||||
return m.role === 'user' ? (
|
||||
<UserMessage
|
||||
key={m.id}
|
||||
message={m}
|
||||
projectId={projectId}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
key={m.id}
|
||||
message={m}
|
||||
streaming={messageStreaming}
|
||||
projectId={projectId}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
isLast={m.id === lastAssistantId}
|
||||
nextUserContent={nextUserContentByAssistantId.get(m.id)}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onContinueRemainingTasks={
|
||||
m.id === lastAssistantId && onContinueRemainingTasks
|
||||
? (todos) => onContinueRemainingTasks(m, todos)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
return (
|
||||
<Fragment key={m.id}>
|
||||
{showDaySeparator ? <DaySeparator ts={messageTime(m)} /> : null}
|
||||
{m.role === 'user' ? (
|
||||
<UserMessage
|
||||
message={m}
|
||||
projectId={projectId}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
t={t}
|
||||
/>
|
||||
) : (
|
||||
<AssistantMessage
|
||||
message={m}
|
||||
streaming={messageStreaming}
|
||||
projectId={projectId}
|
||||
projectFileNames={projectFileNames}
|
||||
onRequestOpenFile={onRequestOpenFile}
|
||||
isLast={m.id === lastAssistantId}
|
||||
nextUserContent={nextUserContentByAssistantId.get(m.id)}
|
||||
onSubmitForm={onSubmitForm}
|
||||
onContinueRemainingTasks={
|
||||
m.id === lastAssistantId && onContinueRemainingTasks
|
||||
? (todos) => onContinueRemainingTasks(m, todos)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{error ? <div className="msg error">{error}</div> : null}
|
||||
|
|
@ -516,7 +521,10 @@ function UserMessage({
|
|||
const attachments = message.attachments ?? [];
|
||||
return (
|
||||
<div className="msg user">
|
||||
<div className="role">{t('chat.you')}</div>
|
||||
<div className="role">
|
||||
<span>{t('chat.you')}</span>
|
||||
<MessageTimestamp message={message} t={t} />
|
||||
</div>
|
||||
{attachments.length > 0 ? (
|
||||
<div className="user-attachments">
|
||||
{attachments.map((a) => {
|
||||
|
|
@ -552,6 +560,33 @@ function UserMessage({
|
|||
);
|
||||
}
|
||||
|
||||
function DaySeparator({ ts }: { ts: number | undefined }) {
|
||||
if (!ts) return null;
|
||||
return (
|
||||
<div className="chat-day-separator" role="separator">
|
||||
<time dateTime={new Date(ts).toISOString()}>{dayLabel(ts)}</time>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageTimestamp({ message, t }: { message: ChatMessage; t: TranslateFn }) {
|
||||
const ts = messageTime(message);
|
||||
if (!ts) return null;
|
||||
return (
|
||||
<time className="msg-time" dateTime={new Date(ts).toISOString()} title={exactDateTime(ts)}>
|
||||
{relativeTimeLong(ts, t)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldShowDaySeparator(prev: ChatMessage | undefined, curr: ChatMessage): boolean {
|
||||
const currTime = messageTime(curr);
|
||||
if (!currTime) return false;
|
||||
const prevTime = prev ? messageTime(prev) : undefined;
|
||||
if (!prevTime) return true;
|
||||
return dayKey(prevTime) !== dayKey(currTime);
|
||||
}
|
||||
|
||||
function relTime(ts: number, t: TranslateFn): string {
|
||||
const diff = Date.now() - ts;
|
||||
const min = 60_000;
|
||||
|
|
|
|||
|
|
@ -579,6 +579,7 @@ export function ProjectView({
|
|||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
createdAt: startedAt,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
};
|
||||
const selectedAgent =
|
||||
|
|
@ -599,6 +600,7 @@ export function ProjectView({
|
|||
agentId: assistantAgentId,
|
||||
agentName: assistantAgentName,
|
||||
events: [],
|
||||
createdAt: startedAt,
|
||||
runStatus: config.mode === 'daemon' ? 'running' : undefined,
|
||||
startedAt,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -556,6 +556,9 @@ code {
|
|||
word-wrap: break-word;
|
||||
}
|
||||
.msg .role {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
text-transform: none;
|
||||
color: var(--text-strong);
|
||||
|
|
@ -563,6 +566,29 @@ code {
|
|||
letter-spacing: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg-time {
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-day-separator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-faint);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
.chat-day-separator::before,
|
||||
.chat-day-separator::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
flex: 1;
|
||||
}
|
||||
.msg.user .role::before { content: ''; }
|
||||
.msg.user .user-text { white-space: pre-wrap; color: var(--text); }
|
||||
.msg.assistant .prose { margin-top: 4px; }
|
||||
|
|
|
|||
31
apps/web/src/utils/chatTime.test.ts
Normal file
31
apps/web/src/utils/chatTime.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChatMessage } from '../types';
|
||||
import { messageTime } from './chatTime';
|
||||
|
||||
describe('messageTime', () => {
|
||||
it('uses assistant startedAt before persisted createdAt', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
startedAt: 100,
|
||||
createdAt: 200,
|
||||
endedAt: 300,
|
||||
};
|
||||
|
||||
expect(messageTime(message)).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps user createdAt as the primary timestamp', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Build this',
|
||||
startedAt: 100,
|
||||
createdAt: 200,
|
||||
};
|
||||
|
||||
expect(messageTime(message)).toBe(200);
|
||||
});
|
||||
});
|
||||
44
apps/web/src/utils/chatTime.ts
Normal file
44
apps/web/src/utils/chatTime.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { ChatMessage } from '../types';
|
||||
import type { Dict } from '../i18n/types';
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
||||
export function messageTime(message: ChatMessage): number | undefined {
|
||||
if (message.role === 'assistant') {
|
||||
return message.startedAt ?? message.createdAt ?? message.endedAt;
|
||||
}
|
||||
return message.createdAt ?? message.startedAt ?? message.endedAt;
|
||||
}
|
||||
|
||||
export function dayKey(ts: number): string {
|
||||
const d = new Date(ts);
|
||||
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||
}
|
||||
|
||||
export function dayLabel(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function exactDateTime(ts: number): string {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
export function relativeTimeLong(ts: number, t: TranslateFn): string {
|
||||
const diff = Math.max(0, Date.now() - ts);
|
||||
const min = 60_000;
|
||||
const hr = 60 * min;
|
||||
const day = 24 * hr;
|
||||
if (diff < min) return t('common.justNow');
|
||||
if (diff < hr) return t('common.minutesAgo', { n: Math.floor(diff / min) });
|
||||
if (diff < day) return t('common.hoursAgo', { n: Math.floor(diff / hr) });
|
||||
if (diff < 7 * day) return t('common.daysAgo', { n: Math.floor(diff / day) });
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
77
e2e/tests/conversation-timestamps.test.tsx
Normal file
77
e2e/tests/conversation-timestamps.test.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatPane } from '../../apps/web/src/components/ChatPane';
|
||||
import type { ChatMessage } from '../../apps/web/src/types';
|
||||
|
||||
function renderChatPane(messages: ChatMessage[]) {
|
||||
return render(
|
||||
<ChatPane
|
||||
messages={messages}
|
||||
streaming={false}
|
||||
error={null}
|
||||
projectId="project-1"
|
||||
projectFiles={[]}
|
||||
onEnsureProject={async () => 'project-1'}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
conversations={[]}
|
||||
activeConversationId={null}
|
||||
onSelectConversation={() => {}}
|
||||
onDeleteConversation={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('conversation timestamps', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows inline relative message times with exact hover text', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
|
||||
|
||||
renderChatPane([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Create a landing page',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
createdAt: Date.parse('2025-01-15T12:01:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const firstTime = screen.getByText('2h ago');
|
||||
expect(firstTime.tagName).toBe('TIME');
|
||||
expect(firstTime.getAttribute('title')).toContain('2025');
|
||||
expect(screen.getByText('1h ago').tagName).toBe('TIME');
|
||||
});
|
||||
|
||||
it('adds day separators when a conversation crosses days', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-16T14:00:00Z'));
|
||||
|
||||
renderChatPane([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'First request',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'Follow-up',
|
||||
createdAt: Date.parse('2025-01-16T12:00:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -74,6 +74,7 @@ export interface ChatMessage {
|
|||
agentId?: string;
|
||||
agentName?: string;
|
||||
events?: PersistedAgentEvent[];
|
||||
createdAt?: number;
|
||||
runId?: string;
|
||||
runStatus?: ChatRunStatus;
|
||||
lastRunEventId?: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue