diff --git a/packages/acp-bridge/src/bridgeClient.test.ts b/packages/acp-bridge/src/bridgeClient.test.ts index 90c642bf4d..53f526e1f4 100644 --- a/packages/acp-bridge/src/bridgeClient.test.ts +++ b/packages/acp-bridge/src/bridgeClient.test.ts @@ -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) { + 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[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[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 diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 507427e0b5..258ea213d9 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -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 }) + ._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 } } : {}), }); } diff --git a/packages/web-shell/client/adapters/messageTypes.ts b/packages/web-shell/client/adapters/messageTypes.ts index 77404d94b0..6aa8855832 100644 --- a/packages/web-shell/client/adapters/messageTypes.ts +++ b/packages/web-shell/client/adapters/messageTypes.ts @@ -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; diff --git a/packages/web-shell/client/adapters/transcriptToMessages.test.ts b/packages/web-shell/client/adapters/transcriptToMessages.test.ts index af7d816383..635a3ce849 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.test.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.test.ts @@ -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, }, ]); }); diff --git a/packages/web-shell/client/adapters/transcriptToMessages.ts b/packages/web-shell/client/adapters/transcriptToMessages.ts index 3ac2456776..4fd7033a94 100644 --- a/packages/web-shell/client/adapters/transcriptToMessages.ts +++ b/packages/web-shell/client/adapters/transcriptToMessages.ts @@ -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, }); } diff --git a/packages/web-shell/client/components/MessageItem.tsx b/packages/web-shell/client/components/MessageItem.tsx index b0763ce7dc..ac3d369ba5 100644 --- a/packages/web-shell/client/components/MessageItem.tsx +++ b/packages/web-shell/client/components/MessageItem.tsx @@ -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 ; - case 'assistant': - return ( - - ); - case 'tool_group': - return ( - - ); - case 'plan': - return ; - case 'system': - return ( - - ); - case 'user_shell': - return ( - - ); - case 'btw': - return ( - - ); - case 'insight_progress': - return ( - - ); - case 'insight_ready': - return ; - case 'insight_error': - return ( -
- {message.error} -
- ); - default: - return null; - } + const body = ((): ReactElement | null => { + switch (message.role) { + case 'user': + return ( + + ); + case 'assistant': + return ( + + ); + case 'tool_group': + return ( + + ); + case 'plan': + return ; + case 'system': + return ( + + ); + case 'user_shell': + return ( + + ); + case 'btw': + return ( + + ); + case 'insight_progress': + return ( + + ); + case 'insight_ready': + return ; + case 'insight_error': + return ( +
+ {message.error} +
+ ); + default: + return null; + } + })(); + + if (body === null) return null; + + return ( + {body} + ); }, 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 ( diff --git a/packages/web-shell/client/components/MessageTimestamp.module.css b/packages/web-shell/client/components/MessageTimestamp.module.css new file mode 100644 index 0000000000..edb5761bae --- /dev/null +++ b/packages/web-shell/client/components/MessageTimestamp.module.css @@ -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; +} diff --git a/packages/web-shell/client/components/MessageTimestamp.test.tsx b/packages/web-shell/client/components/MessageTimestamp.test.tsx new file mode 100644 index 0000000000..c179812314 --- /dev/null +++ b/packages/web-shell/client/components/MessageTimestamp.test.tsx @@ -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( + +
body
+
, + ); + + 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( + +
body
+
, + ); + + 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); + }); +}); diff --git a/packages/web-shell/client/components/MessageTimestamp.tsx b/packages/web-shell/client/components/MessageTimestamp.tsx new file mode 100644 index 0000000000..33c5681d0e --- /dev/null +++ b/packages/web-shell/client/components/MessageTimestamp.tsx @@ -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 ( +
+ {children} + +
+ ); +} + +/** + * 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}`; +}