feat(web-shell): show message time on hover (#5079)

* fix(acp-bridge): preserve original timestamp when replaying session history

History replay re-emits each persisted record with its original epoch-ms time nested in update._meta, but BridgeClient.sessionUpdate published the frame without lifting it to the envelope. EventBus.publish then stamped envelope _meta.serverTimestamp with publish-time Date.now(), which the client's extractServerTimestamp picks up at higher priority than the nested original — so a resumed session rendered every historical message at the resume moment instead of when it was sent.

Lift update._meta.timestamp (or serverTimestamp) to the envelope serverTimestamp so EventBus preserves it. Live updates without such a timestamp keep the Date.now() fallback unchanged.

* feat(web-shell): show each history message's time on hover

Carry each transcript block's wall-clock time (serverTimestamp ?? clientReceivedAt) onto every message and reveal it as a CSS-only hover tooltip in the message list. Same-day messages show HH:mm:ss; older ones show yyyy-MM-dd HH:mm:ss (local time, zero-padded).
This commit is contained in:
Shaojin Wen 2026-06-13 15:30:44 +08:00 committed by GitHub
parent aebf82cd29
commit 84d01e7070
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 434 additions and 89 deletions

View file

@ -476,6 +476,66 @@ describe('BridgeClient — A2UI session update publishing', () => {
});
});
describe('BridgeClient — original timestamp preservation', () => {
const noPermissionFlow = () => {
throw new Error('test: permission flow should not run');
};
function makeClientFor(sessionId: string, publish: ReturnType<typeof vi.fn>) {
const fakeEntry = { sessionId, events: { publish } };
return new BridgeClient(
((sid: string) => (sid === sessionId ? fakeEntry : undefined)) as never,
noPermissionFlow as never,
{ request: noPermissionFlow } as never,
0,
Infinity,
);
}
it('lifts a replayed update._meta.timestamp to the envelope serverTimestamp', async () => {
const publish = vi.fn().mockReturnValue(true);
const client = makeClientFor('sess:replay', publish);
// A previous-day epoch — must survive to the envelope so EventBus does not
// overwrite it with publish-time Date.now().
const original = 1_700_000_000_000;
await client.sessionUpdate({
sessionId: 'sess:replay',
update: {
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: 'hi' },
_meta: { timestamp: original },
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(publish).toHaveBeenCalledTimes(1);
const frame = publish.mock.calls[0][0] as {
_meta?: { serverTimestamp?: number };
};
expect(frame._meta?.serverTimestamp).toBe(original);
});
it('passes no envelope _meta for live updates without a timestamp', async () => {
const publish = vi.fn().mockReturnValue(true);
const client = makeClientFor('sess:live', publish);
await client.sessionUpdate({
sessionId: 'sess:live',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'yo' },
},
} as Parameters<BridgeClient['sessionUpdate']>[0]);
expect(publish).toHaveBeenCalledTimes(1);
const frame = publish.mock.calls[0][0] as {
_meta?: { serverTimestamp?: number };
};
// No envelope _meta → EventBus.publish applies its own Date.now() fallback.
expect(frame._meta).toBeUndefined();
});
});
/**
* Wenshao review #4335 / 3271978365 `requestPermission`'s pre-publish
* `CancelSentinelCollisionError` guard prevents an orphan SSE

View file

@ -431,10 +431,27 @@ export class BridgeClient implements Client {
}
params = a2ui.sanitizedParams;
}
// History replay re-emits each persisted record carrying its ORIGINAL
// wall-clock time as an epoch-ms `timestamp` nested in `update._meta` (set
// by the message/tool emitters). Lift it to the envelope-level
// `serverTimestamp` so `EventBus.publish` preserves it instead of stamping
// publish-time `Date.now()` — otherwise a resumed session renders every
// historical message at the resume moment instead of when it was sent.
// Live updates without such a timestamp pass no envelope `_meta` and keep
// the EventBus `Date.now()` fallback unchanged.
const updateMeta = (params.update as { _meta?: Record<string, unknown> })
._meta;
const originalTs =
updateMeta?.['serverTimestamp'] ?? updateMeta?.['timestamp'];
const serverTimestamp =
typeof originalTs === 'number' && Number.isFinite(originalTs)
? originalTs
: undefined;
events.publish({
type: 'session_update',
data: params,
...originator,
...(serverTimestamp !== undefined ? { _meta: { serverTimestamp } } : {}),
});
}

View file

@ -60,14 +60,28 @@ export interface DaemonMessageTodoItem {
priority?: 'high' | 'medium' | 'low';
}
export interface DaemonUserMessage {
/**
* Fields shared by every history message. Kept as a base interface so a new
* cross-cutting field is declared once rather than on each role.
*/
export interface DaemonMessageMeta {
/**
* Wall-clock epoch milliseconds when the backing transcript block was first
* observed, populated from `serverTimestamp ?? clientReceivedAt`. Surfaced
* as a hover tooltip in the message list. Undefined for synthetic messages
* that have no backing block.
*/
timestamp?: number;
}
export interface DaemonUserMessage extends DaemonMessageMeta {
id: string;
role: 'user';
content: string;
images?: Array<{ data: string; mimeType: string }>;
}
export interface DaemonAssistantMessage {
export interface DaemonAssistantMessage extends DaemonMessageMeta {
id: string;
role: 'assistant';
content: string;
@ -75,19 +89,19 @@ export interface DaemonAssistantMessage {
isStreaming?: boolean;
}
export interface DaemonToolGroupMessage {
export interface DaemonToolGroupMessage extends DaemonMessageMeta {
id: string;
role: 'tool_group';
tools: DaemonMessageToolCall[];
}
export interface DaemonPlanMessage {
export interface DaemonPlanMessage extends DaemonMessageMeta {
id: string;
role: 'plan';
todos: DaemonMessageTodoItem[];
}
export interface DaemonSystemMessage {
export interface DaemonSystemMessage extends DaemonMessageMeta {
id: string;
role: 'system';
content: string;
@ -95,7 +109,7 @@ export interface DaemonSystemMessage {
retryable?: boolean;
}
export interface DaemonUserShellMessage {
export interface DaemonUserShellMessage extends DaemonMessageMeta {
id: string;
role: 'user_shell';
command: string;
@ -103,7 +117,7 @@ export interface DaemonUserShellMessage {
cwd?: string;
}
export interface DaemonBtwMessage {
export interface DaemonBtwMessage extends DaemonMessageMeta {
id: string;
role: 'btw';
question: string;
@ -111,7 +125,7 @@ export interface DaemonBtwMessage {
isPending: boolean;
}
export interface DaemonInsightProgressMessage {
export interface DaemonInsightProgressMessage extends DaemonMessageMeta {
id: string;
role: 'insight_progress';
stage: string;
@ -119,13 +133,13 @@ export interface DaemonInsightProgressMessage {
detail?: string;
}
export interface DaemonInsightReadyMessage {
export interface DaemonInsightReadyMessage extends DaemonMessageMeta {
id: string;
role: 'insight_ready';
path: string;
}
export interface DaemonInsightErrorMessage {
export interface DaemonInsightErrorMessage extends DaemonMessageMeta {
id: string;
role: 'insight_error';
error: string;

View file

@ -148,6 +148,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
{
id: 'plan-1',
role: 'plan',
timestamp: 1,
todos: [
{
id: 'plan-0',
@ -183,6 +184,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
{
id: 'plan-1',
role: 'plan',
timestamp: 1,
todos: [
{
id: 'plan-1',
@ -231,6 +233,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
{
id: 'tg-todo-1',
role: 'tool_group',
timestamp: 1,
tools: [
expect.objectContaining({
callId: 'todo-call-1',
@ -255,6 +258,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
{
id: 'tg-t1',
role: 'tool_group',
timestamp: 1,
tools: [
expect.objectContaining({ callId: 'tc1', toolName: 'Read' }),
expect.objectContaining({ callId: 'tc2', toolName: 'Grep' }),
@ -378,6 +382,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
{
id: 'insight-1-ip',
role: 'insight_progress',
timestamp: 1,
stage: 'scan',
progress: 0.5,
detail: 'reading',
@ -396,11 +401,36 @@ describe('transcriptBlocksToDaemonMessages', () => {
]);
expect(messages).toEqual([
{ id: 'insight-1-t-0', role: 'assistant', content: 'before' },
{ id: 'insight-1-ir-0', role: 'insight_ready', path: '/tmp/report.md' },
{ id: 'insight-1-t-2', role: 'assistant', content: 'middle' },
{ id: 'insight-1-ie-0', role: 'insight_error', error: 'boom' },
{ id: 'insight-1-t-4', role: 'assistant', content: 'after' },
{
id: 'insight-1-t-0',
role: 'assistant',
content: 'before',
timestamp: 1,
},
{
id: 'insight-1-ir-0',
role: 'insight_ready',
path: '/tmp/report.md',
timestamp: 1,
},
{
id: 'insight-1-t-2',
role: 'assistant',
content: 'middle',
timestamp: 1,
},
{
id: 'insight-1-ie-0',
role: 'insight_error',
error: 'boom',
timestamp: 1,
},
{
id: 'insight-1-t-4',
role: 'assistant',
content: 'after',
timestamp: 1,
},
]);
});
@ -813,6 +843,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
role: 'assistant',
content: 'hello world',
isStreaming: false,
timestamp: 1,
},
]);
});
@ -1876,6 +1907,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
content: 'Connection lost',
variant: 'error',
retryable: false,
timestamp: 1,
},
]);
});
@ -1900,6 +1932,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
content: 'Request failed',
variant: 'error',
retryable: true,
timestamp: 1,
},
]);
});
@ -1922,6 +1955,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
role: 'system',
content: 'Session initialized',
variant: 'info',
timestamp: 1,
},
]);
});
@ -1938,6 +1972,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
content: '',
thinking: 'let me think about this',
isStreaming: false,
timestamp: 1,
},
]);
});
@ -2008,9 +2043,21 @@ describe('transcriptBlocksToDaemonMessages', () => {
]);
expect(messages).toEqual([
{ id: 'a1', role: 'assistant', content: 'first', isStreaming: false },
{ id: 'u1', role: 'user', content: 'question' },
{ id: 'a2', role: 'assistant', content: 'second', isStreaming: false },
{
id: 'a1',
role: 'assistant',
content: 'first',
isStreaming: false,
timestamp: 1,
},
{ id: 'u1', role: 'user', content: 'question', timestamp: 2 },
{
id: 'a2',
role: 'assistant',
content: 'second',
isStreaming: false,
timestamp: 3,
},
]);
});
@ -2027,6 +2074,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
content: 'here is my answer',
thinking: 'analyzing...',
isStreaming: false,
timestamp: 1,
},
]);
});
@ -2074,6 +2122,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
role: 'system',
content: 'Request cancelled.',
variant: 'info',
timestamp: 20,
},
]);
});
@ -2090,6 +2139,7 @@ describe('transcriptBlocksToDaemonMessages', () => {
role: 'system',
content: '请求已取消。',
variant: 'info',
timestamp: 20,
},
]);
});

View file

@ -85,6 +85,10 @@ export function transcriptBlocksToDaemonMessages(
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
// Wall-clock of this block, surfaced as a hover tooltip on the rendered
// message. Prefer the daemon-authoritative stamp so every client agrees;
// fall back to the local receive time when the daemon left it unset.
const blockTime = block.serverTimestamp ?? block.clientReceivedAt;
switch (block.kind) {
case 'user': {
@ -95,6 +99,7 @@ export function transcriptBlocksToDaemonMessages(
id: block.id,
role: 'user',
content: textBlock.text,
timestamp: blockTime,
};
// Attach images if present
if (textBlock.images && textBlock.images.length > 0) {
@ -134,6 +139,7 @@ export function transcriptBlocksToDaemonMessages(
id: `${block.id}-ir-${readyCount++}`,
role: 'insight_ready',
path: seg.data.path,
timestamp: blockTime,
});
} else if (seg.data.type === 'insight_error') {
hasTerminal = true;
@ -141,6 +147,7 @@ export function transcriptBlocksToDaemonMessages(
id: `${block.id}-ie-${errorCount++}`,
role: 'insight_error',
error: seg.data.error,
timestamp: blockTime,
});
}
} else {
@ -148,6 +155,7 @@ export function transcriptBlocksToDaemonMessages(
id: `${block.id}-t-${messages.length}`,
role: 'assistant',
content: seg.text,
timestamp: blockTime,
});
currentAssistantIdx = messages.length - 1;
}
@ -159,6 +167,7 @@ export function transcriptBlocksToDaemonMessages(
stage: lastProgress.stage,
progress: lastProgress.progress,
detail: lastProgress.detail,
timestamp: blockTime,
});
}
needsNewContentMessage = true;
@ -182,6 +191,7 @@ export function transcriptBlocksToDaemonMessages(
role: 'assistant',
content: textBlock.text,
isStreaming: textBlock.streaming,
timestamp: blockTime,
});
currentAssistantIdx = messages.length - 1;
needsNewContentMessage = false;
@ -221,6 +231,7 @@ export function transcriptBlocksToDaemonMessages(
content: '',
thinking: textBlock.text,
isStreaming: textBlock.streaming,
timestamp: blockTime,
});
currentAssistantIdx = messages.length - 1;
needsNewContentMessage = false;
@ -254,7 +265,7 @@ export function transcriptBlocksToDaemonMessages(
break;
}
appendToolCallMessage(messages, block.id, toolCall);
appendToolCallMessage(messages, block.id, toolCall, blockTime);
toolsByCallId.set(toolCall.callId, toolCall);
needsNewContentMessage = true;
break;
@ -300,6 +311,7 @@ export function transcriptBlocksToDaemonMessages(
rawOutput: shellBlock.text,
},
],
timestamp: blockTime,
});
needsNewContentMessage = true;
}
@ -314,6 +326,7 @@ export function transcriptBlocksToDaemonMessages(
command: shellBlock.command,
output: shellBlock.text,
...(shellBlock.cwd ? { cwd: shellBlock.cwd } : {}),
timestamp: blockTime,
});
needsNewContentMessage = true;
break;
@ -367,13 +380,23 @@ export function transcriptBlocksToDaemonMessages(
if (!isSubAgentPermission) {
permissionToolCall.status = 'in_progress';
}
appendToolCallMessage(messages, block.id, permissionToolCall);
appendToolCallMessage(
messages,
block.id,
permissionToolCall,
blockTime,
);
toolsByCallId.set(permissionToolCall.callId, permissionToolCall);
needsNewContentMessage = true;
} else {
permissionToolCall.status = 'failed';
permissionToolCall.endTime = permBlock.updatedAt;
appendToolCallMessage(messages, block.id, permissionToolCall);
appendToolCallMessage(
messages,
block.id,
permissionToolCall,
blockTime,
);
toolsByCallId.set(permissionToolCall.callId, permissionToolCall);
needsNewContentMessage = true;
}
@ -392,6 +415,7 @@ export function transcriptBlocksToDaemonMessages(
id: block.id,
role: 'plan',
todos,
timestamp: blockTime,
});
needsNewContentMessage = true;
break;
@ -405,6 +429,7 @@ export function transcriptBlocksToDaemonMessages(
role: 'system',
content: text,
variant: 'info',
timestamp: blockTime,
});
needsNewContentMessage = true;
break;
@ -418,6 +443,7 @@ export function transcriptBlocksToDaemonMessages(
content: errorBlock.text,
variant: 'error',
retryable: errorBlock.source === 'turn_error',
timestamp: blockTime,
});
needsNewContentMessage = true;
break;
@ -429,6 +455,7 @@ export function transcriptBlocksToDaemonMessages(
role: 'system',
content: promptCancelledText,
variant: 'info',
timestamp: blockTime,
});
needsNewContentMessage = true;
break;
@ -457,6 +484,7 @@ function appendToolCallMessage(
messages: DaemonMessage[],
blockId: string,
toolCall: DaemonMessageToolCall,
timestamp?: number,
): void {
// Native CLI groups every tool call of one scheduler batch into a single
// bordered tool_group (mapToDisplay in useReactToolScheduler). The daemon
@ -485,6 +513,7 @@ function appendToolCallMessage(
id: `tg-${blockId}`,
role: 'tool_group',
tools: [toolCall],
timestamp,
});
}

View file

@ -1,10 +1,11 @@
import { memo } from 'react';
import { memo, type ReactElement } from 'react';
import type {
ACPToolCall,
Message,
PermissionRequest,
TodoItem,
} from '../adapters/types';
import { MessageTimestamp } from './MessageTimestamp';
import { UserMessage } from './messages/UserMessage';
import { AssistantMessage } from './messages/AssistantMessage';
import { SystemMessage } from './messages/SystemMessage';
@ -43,73 +44,83 @@ export const MessageItem = memo(function MessageItem({
onRetryClick,
shellOutputMaxLines,
}: MessageItemProps) {
switch (message.role) {
case 'user':
return <UserMessage content={message.content} images={message.images} />;
case 'assistant':
return (
<AssistantMessage
content={message.content}
thinking={message.thinking}
isStreaming={message.isStreaming}
/>
);
case 'tool_group':
return (
<ToolGroup
tools={message.tools}
pendingApproval={pendingApproval}
onConfirm={onConfirm}
workspaceCwd={workspaceCwd}
shellOutputMaxLines={shellOutputMaxLines}
/>
);
case 'plan':
return <PlanMessage todos={message.todos} />;
case 'system':
return (
<SystemMessage
content={message.content}
variant={message.variant}
onShowContextDetail={onShowContextDetail}
isLatest={isLatest}
showRetryHint={showRetryHint && message.retryable === true}
onRetryClick={onRetryClick}
/>
);
case 'user_shell':
return (
<UserShellMessage command={message.command} output={message.output} />
);
case 'btw':
return (
<BtwMessage
question={message.question}
answer={message.answer}
isPending={message.isPending}
/>
);
case 'insight_progress':
return (
<InsightProgress
progress={{
stage: message.stage,
progress: message.progress,
detail: message.detail,
}}
/>
);
case 'insight_ready':
return <InsightReady path={message.path} />;
case 'insight_error':
return (
<div style={{ color: 'var(--error-color, #e06c75)' }}>
{message.error}
</div>
);
default:
return null;
}
const body = ((): ReactElement | null => {
switch (message.role) {
case 'user':
return (
<UserMessage content={message.content} images={message.images} />
);
case 'assistant':
return (
<AssistantMessage
content={message.content}
thinking={message.thinking}
isStreaming={message.isStreaming}
/>
);
case 'tool_group':
return (
<ToolGroup
tools={message.tools}
pendingApproval={pendingApproval}
onConfirm={onConfirm}
workspaceCwd={workspaceCwd}
shellOutputMaxLines={shellOutputMaxLines}
/>
);
case 'plan':
return <PlanMessage todos={message.todos} />;
case 'system':
return (
<SystemMessage
content={message.content}
variant={message.variant}
onShowContextDetail={onShowContextDetail}
isLatest={isLatest}
showRetryHint={showRetryHint && message.retryable === true}
onRetryClick={onRetryClick}
/>
);
case 'user_shell':
return (
<UserShellMessage command={message.command} output={message.output} />
);
case 'btw':
return (
<BtwMessage
question={message.question}
answer={message.answer}
isPending={message.isPending}
/>
);
case 'insight_progress':
return (
<InsightProgress
progress={{
stage: message.stage,
progress: message.progress,
detail: message.detail,
}}
/>
);
case 'insight_ready':
return <InsightReady path={message.path} />;
case 'insight_error':
return (
<div style={{ color: 'var(--error-color, #e06c75)' }}>
{message.error}
</div>
);
default:
return null;
}
})();
if (body === null) return null;
return (
<MessageTimestamp timestamp={message.timestamp}>{body}</MessageTimestamp>
);
}, areMessageItemPropsEqual);
function areMessageItemPropsEqual(
@ -130,6 +141,7 @@ function areMessageItemPropsEqual(
function areMessagesEqual(prev: Message, next: Message): boolean {
if (prev === next) return true;
if (prev.id !== next.id || prev.role !== next.role) return false;
if (prev.timestamp !== next.timestamp) return false;
switch (prev.role) {
case 'user':
return (

View file

@ -0,0 +1,28 @@
.row {
position: relative;
}
/*
* Anchored inside the message's top-right corner rather than floating above
* it: the message list (`.list`) is `overflow-y: auto`, so a tooltip spilling
* outside the row box would be clipped or trigger a scrollbar. Staying within
* the row keeps it visible at the scroll edges. `pointer-events: none` lets
* clicks and inner hover interactions (tool expand, links) pass through.
*/
.tip {
position: absolute;
top: 2px;
right: 4px;
z-index: 5;
color: var(--text-dimmed);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.12s ease;
}
.row:hover > .tip {
opacity: 1;
}

View file

@ -0,0 +1,82 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest';
import { act, type ReactNode } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MessageTimestamp, formatTimestamp } from './MessageTimestamp';
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
});
function render(node: ReactNode): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => root.render(node));
mounted.push({ root, container });
return container;
}
describe('formatTimestamp', () => {
// Built from local-time parts so expectations are timezone independent
// (month is 0-based: 5 = June).
const now = new Date(2026, 5, 13, 12, 0, 0);
it('shows only HH:mm:ss for a same-day timestamp', () => {
const ts = new Date(2026, 5, 13, 9, 8, 7).getTime();
expect(formatTimestamp(ts, now)).toBe('09:08:07');
});
it('shows full yyyy-MM-dd HH:mm:ss for an earlier day in the same year', () => {
const ts = new Date(2026, 0, 2, 9, 8, 7).getTime();
expect(formatTimestamp(ts, now)).toBe('2026-01-02 09:08:07');
});
it('shows full yyyy-MM-dd HH:mm:ss for a previous year', () => {
// Same month/day as `now` but last year — must not be read as "today".
const ts = new Date(2025, 5, 13, 9, 8, 7).getTime();
expect(formatTimestamp(ts, now)).toBe('2025-06-13 09:08:07');
});
});
describe('MessageTimestamp', () => {
it('reveals the wall-clock time as a hover tooltip when a timestamp is set', () => {
const ts = new Date(2026, 5, 13, 9, 8, 7).getTime();
const container = render(
<MessageTimestamp timestamp={ts}>
<div>body</div>
</MessageTimestamp>,
);
const tip = container.querySelector('span[aria-hidden="true"]');
expect(tip).not.toBeNull();
// Every variant ends in HH:mm:ss; the leading parts depend on the real
// "now", so assert the shape rather than an exact string here.
expect(tip?.textContent).toMatch(/\d{2}:\d{2}:\d{2}$/);
expect(container.textContent).toContain('body');
});
it('renders children unchanged with no tooltip when timestamp is undefined', () => {
const container = render(
<MessageTimestamp>
<div data-testid="child">body</div>
</MessageTimestamp>,
);
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull();
// No wrapper element is introduced: the child stays a direct child of the
// mount container, so message spacing/structure is untouched.
const child = container.querySelector('[data-testid="child"]');
expect(child).not.toBeNull();
expect(child?.parentElement).toBe(container);
});
});

View file

@ -0,0 +1,53 @@
import type { ReactNode } from 'react';
import styles from './MessageTimestamp.module.css';
interface MessageTimestampProps {
/** Wall-clock epoch ms of the message; omitted for synthetic messages. */
timestamp?: number;
children: ReactNode;
}
/**
* Wraps a rendered history message and reveals its wall-clock time as a
* CSS-only tooltip on hover. When the message carries no timestamp the
* children render unchanged, so no empty wrapper is introduced.
*/
export function MessageTimestamp({
timestamp,
children,
}: MessageTimestampProps) {
if (timestamp === undefined) {
return <>{children}</>;
}
return (
<div className={styles.row}>
{children}
<span className={styles.tip} aria-hidden="true">
{formatTimestamp(timestamp)}
</span>
</div>
);
}
/**
* Local-time clock, dropping the date only for same-day timestamps:
* - same day `HH:mm:ss`
* - earlier `yyyy-MM-dd HH:mm:ss`
*
* Fixed order and zero-padded (unlike toLocaleString) so stacked timestamps
* align. `now` is injectable so the branch logic is unit-testable without
* depending on the wall clock.
*/
export function formatTimestamp(ts: number, now: Date = new Date()): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, '0');
const hms = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
const sameDay =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (sameDay) {
return hms;
}
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hms}`;
}