qwen-code/packages/web-shell/client/components/MessageTimestamp.test.tsx
Shaojin Wen 84d01e7070
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).
2026-06-13 15:30:44 +08:00

82 lines
2.9 KiB
TypeScript

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