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:
Siri-Ray 2026-05-02 11:15:07 +08:00 committed by GitHub
parent 85032f530c
commit 0bafc73d24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 263 additions and 32 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

@ -74,6 +74,7 @@ export interface ChatMessage {
agentId?: string;
agentName?: string;
events?: PersistedAgentEvent[];
createdAt?: number;
runId?: string;
runStatus?: ChatRunStatus;
lastRunEventId?: string;