From f9a945d6651c2e592a11f2d0ed2cfb6b45cf7c58 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:39:44 +0800 Subject: [PATCH 1/8] feat(core): present contextual message timestamps Generated-by: Codex --- packages/core/package.json | 1 + .../conversation-message-timestamp.test.ts | 96 ++++++++++++++++ .../src/conversation-message-timestamp.ts | 104 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 packages/core/src/__tests__/conversation-message-timestamp.test.ts create mode 100644 packages/core/src/conversation-message-timestamp.ts diff --git a/packages/core/package.json b/packages/core/package.json index 33a91f50ea..3c8c493f83 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -115,6 +115,7 @@ "./bot-onboarding": "./dist/bot-onboarding.js", "./computer-use": "./dist/computer-use.js", "./connection-error-copy": "./dist/connection-error-copy.js", + "./conversation-message-timestamp": "./dist/conversation-message-timestamp.js", "./git-review": "./dist/git-review.js", "./graph-command": "./dist/graph-command.js", "./project": "./dist/project.js", diff --git a/packages/core/src/__tests__/conversation-message-timestamp.test.ts b/packages/core/src/__tests__/conversation-message-timestamp.test.ts new file mode 100644 index 0000000000..6afde7817f --- /dev/null +++ b/packages/core/src/__tests__/conversation-message-timestamp.test.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + nextConversationMessageTimestampRefreshDelay, + presentConversationMessageTimestamp, +} from '../conversation-message-timestamp.js'; + +const TODAY_NOW = new Date(2026, 7, 24, 20, 0, 0).getTime(); + +describe('conversation message timestamp presentation', () => { + it('shows only the clock for a message on the same local calendar day', () => { + const timestamp = new Date(2026, 7, 24, 14, 30, 0).getTime(); + const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'zh'); + + assert.ok(result); + assert.equal(result.relation, 'today'); + assert.equal(result.datePrefix, ''); + assert.equal(result.fallbackText, '14:30'); + assert.equal(result.isoDateTime, new Date(timestamp).toISOString()); + }); + + it('adds month and day but omits the year across days in the same local year', () => { + const timestamp = new Date(2026, 7, 23, 14, 30, 0).getTime(); + const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'zh'); + + assert.ok(result); + assert.equal(result.relation, 'same_year'); + assert.equal(result.datePrefix, '8月23日 '); + assert.equal(result.fallbackText, '8月23日 14:30'); + assert.doesNotMatch(result.fallbackText, /2026/); + }); + + it('adds the year when the local calendar years differ', () => { + const timestamp = new Date(2025, 7, 23, 14, 30, 0).getTime(); + const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'zh'); + + assert.ok(result); + assert.equal(result.relation, 'other_year'); + assert.equal(result.datePrefix, '2025年8月23日 '); + assert.equal(result.fallbackText, '2025年8月23日 14:30'); + assert.match(result.absoluteLabel, /2025/); + }); + + it('localizes English date punctuation without putting the current year back', () => { + const timestamp = new Date(2026, 7, 23, 14, 30, 0).getTime(); + const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'en'); + + assert.ok(result); + assert.equal(result.relation, 'same_year'); + assert.match(result.fallbackText, /Aug 23/); + assert.doesNotMatch(result.fallbackText, /2026/); + assert.ok(result.datePrefix.length > 'Aug 23'.length); + }); + + it('uses calendar boundaries instead of elapsed 24-hour buckets', () => { + const beforeMidnight = new Date(2026, 7, 23, 23, 59, 0).getTime(); + const afterMidnight = new Date(2026, 7, 24, 0, 1, 0).getTime(); + const result = presentConversationMessageTimestamp(beforeMidnight, afterMidnight, 'zh'); + + assert.ok(result); + assert.equal(result.relation, 'same_year'); + }); + + it('rejects invalid timestamps and invalid reference clocks', () => { + assert.equal(presentConversationMessageTimestamp(Number.NaN, TODAY_NOW, 'zh'), undefined); + assert.equal( + presentConversationMessageTimestamp(TODAY_NOW, Number.POSITIVE_INFINITY, 'zh'), + undefined, + ); + }); + + it('schedules the next refresh at the next local midnight', () => { + const now = new Date(2026, 0, 15, 23, 59, 30).getTime(); + assert.equal(nextConversationMessageTimestampRefreshDelay(now), 30_000); + assert.equal(nextConversationMessageTimestampRefreshDelay(Number.NaN), null); + }); +}); diff --git a/packages/core/src/conversation-message-timestamp.ts b/packages/core/src/conversation-message-timestamp.ts new file mode 100644 index 0000000000..a10885711c --- /dev/null +++ b/packages/core/src/conversation-message-timestamp.ts @@ -0,0 +1,104 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { uiLocaleToIntlLocale, type UiLocale } from './ui-locale.js'; + +export type ConversationMessageDateRelation = 'today' | 'same_year' | 'other_year'; + +export interface ConversationMessageTimestampPresentation { + relation: ConversationMessageDateRelation; + datePrefix: string; + fallbackText: string; + absoluteLabel: string; + isoDateTime: string; +} + +function localDateRelation(date: Date, now: Date): ConversationMessageDateRelation { + if ( + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate() + ) { + return 'today'; + } + return date.getFullYear() === now.getFullYear() ? 'same_year' : 'other_year'; +} + +function visibleFormatOptions( + relation: ConversationMessageDateRelation, +): Intl.DateTimeFormatOptions { + const clock: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' }; + if (relation === 'today') return clock; + if (relation === 'same_year') { + return { month: 'short', day: 'numeric', ...clock }; + } + return { year: 'numeric', month: 'short', day: 'numeric', ...clock }; +} + +function prefixBeforeClock( + parts: Intl.DateTimeFormatPart[], + relation: ConversationMessageDateRelation, +): string { + if (relation === 'today') return ''; + const hourIndex = parts.findIndex((part) => part.type === 'hour'); + return hourIndex < 0 + ? '' + : parts + .slice(0, hourIndex) + .map((part) => part.value) + .join(''); +} + +export function presentConversationMessageTimestamp( + timestamp: number, + now: number = Date.now(), + locale: UiLocale = 'zh', +): ConversationMessageTimestampPresentation | undefined { + if (!Number.isFinite(timestamp) || !Number.isFinite(now)) return undefined; + const date = new Date(timestamp); + const nowDate = new Date(now); + if (Number.isNaN(date.getTime()) || Number.isNaN(nowDate.getTime())) return undefined; + + const relation = localDateRelation(date, nowDate); + const intlLocale = uiLocaleToIntlLocale(locale); + const visibleFormatter = new Intl.DateTimeFormat(intlLocale, visibleFormatOptions(relation)); + const parts = visibleFormatter.formatToParts(date); + + return { + relation, + datePrefix: prefixBeforeClock(parts, relation), + fallbackText: parts.map((part) => part.value).join(''), + absoluteLabel: new Intl.DateTimeFormat(intlLocale, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date), + isoDateTime: date.toISOString(), + }; +} + +export function nextConversationMessageTimestampRefreshDelay( + now: number = Date.now(), +): number | null { + if (!Number.isFinite(now)) return null; + const nextMidnight = new Date(now); + if (Number.isNaN(nextMidnight.getTime())) return null; + nextMidnight.setHours(24, 0, 0, 0); + const delay = nextMidnight.getTime() - now; + return Number.isFinite(delay) && delay > 0 ? delay : null; +} From b6559717cefee18ec58b3b42004a1cfd08c85d93 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:41:07 +0800 Subject: [PATCH 2/8] feat(ui): add contextual user message timestamps Generated-by: Codex --- .../conversation-message-timestamp.test.tsx | 177 ++++++++++++++++++ packages/ui/src/chat-turn.tsx | 11 +- .../ui/src/conversation-message-timestamp.tsx | 60 ++++++ 3 files changed, 241 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/__tests__/conversation-message-timestamp.test.tsx create mode 100644 packages/ui/src/conversation-message-timestamp.tsx diff --git a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx new file mode 100644 index 0000000000..4b13584d52 --- /dev/null +++ b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { parseHTML } from 'linkedom'; +import { ConversationMessageTimestamp } from '../conversation-message-timestamp.js'; +import { TurnView } from '../chat-turn.js'; +import { LocaleProvider } from '../locale-context.js'; +import type { TurnViewModel } from '../materialize.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; + +afterEach(() => { + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +function renderTimestamp(value: number, now: number) { + const originalNow = Date.now; + Date.now = () => now; + try { + const markup = renderToStaticMarkup( + + + , + ); + return parseHTML(`${markup}`).document; + } finally { + Date.now = originalNow; + } +} + +test('composes a date prefix with the unchanged Astryx time rendering', () => { + const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); + const value = new Date(2025, 7, 23, 14, 30, 0).getTime(); + const document = renderTimestamp(value, now); + const presentation = document.querySelector('.maka-message-time-presentation'); + + assert.ok(presentation); + assert.equal(presentation.getAttribute('data-date-relation'), 'other_year'); + assert.equal( + presentation.querySelector('.maka-message-date-prefix')?.textContent, + '2025年8月23日 ', + ); + assert.ok(presentation.querySelector('time')); + assert.equal(presentation.querySelector('[aria-hidden="true"]') !== null, true); + assert.match( + presentation.querySelector('.maka-visually-hidden')?.textContent ?? '', + /2025/, + ); +}); + +test('omits the visual date prefix for a message from today', () => { + const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); + const value = new Date(2026, 7, 24, 14, 30, 0).getTime(); + const document = renderTimestamp(value, now); + + assert.equal( + document.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), + 'today', + ); + assert.equal(document.querySelector('.maka-message-date-prefix'), null); +}); + +test('TurnView routes original and steering user timestamps through the adapter', () => { + const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); + const originalNow = Date.now; + Date.now = () => now; + try { + const turn: TurnViewModel = { + turnId: 'turn-1', + status: 'completed', + partialOutputRetained: false, + user: { + id: 'original', + role: 'user', + text: 'original request', + ts: new Date(2026, 7, 24, 14, 30, 0).getTime(), + }, + tools: [], + notes: [], + startedAt: now, + timeline: [ + { + kind: 'user', + message: { + id: 'steer-1', + role: 'user', + text: 'steering request', + ts: new Date(2025, 7, 23, 14, 30, 0).getTime(), + }, + messageId: 'steer-1', + }, + ], + }; + const markup = renderToStaticMarkup( + + + , + ); + const document = parseHTML(`${markup}`).document; + assert.deepEqual( + [...document.querySelectorAll('.maka-message-time-presentation')].map((node) => + node.getAttribute('data-date-relation'), + ), + ['today', 'other_year'], + ); + } finally { + Date.now = originalNow; + } +}); + +test('reclassifies a mounted timestamp after local midnight', async (context) => { + const start = new Date(2026, 0, 15, 23, 59, 50).getTime(); + const value = new Date(2026, 0, 15, 23, 59, 30).getTime(); + context.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: start }); + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + + try { + await act(() => { + root.render( + + + , + ); + }); + assert.equal( + container.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), + 'today', + ); + + await act(() => context.mock.timers.tick(10_000)); + assert.equal( + container.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), + 'same_year', + ); + } finally { + await act(() => root.unmount()); + context.mock.timers.reset(); + } +}); diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index e0e6f27383..06aebf7751 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -43,7 +43,6 @@ import { IconButton as UiIconButton, Spinner, Thumbnail, - Timestamp, Token, useLightbox, } from '@astryxdesign/core'; @@ -72,6 +71,7 @@ import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { AstryxLocaleProvider } from './astryx-i18n.js'; import { InlineReferenceText } from './inline-reference.js'; +import { ConversationMessageTimestamp } from './conversation-message-timestamp.js'; export function LocalizedChatMessage({ accessibleLabel, @@ -189,12 +189,9 @@ const UserMessageBody = memo(function UserMessageBody(props: { ) - ) : undefined + props.ts !== undefined + ? + : undefined } footer={ <> diff --git a/packages/ui/src/conversation-message-timestamp.tsx b/packages/ui/src/conversation-message-timestamp.tsx new file mode 100644 index 0000000000..ede27780b7 --- /dev/null +++ b/packages/ui/src/conversation-message-timestamp.tsx @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from 'react'; +import { Timestamp } from '@astryxdesign/core'; +import { + nextConversationMessageTimestampRefreshDelay, + presentConversationMessageTimestamp, +} from '@maka/core/conversation-message-timestamp'; +import { useUiLocale } from './locale-context.js'; + +export function ConversationMessageTimestamp(props: { value: number }) { + const locale = useUiLocale(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const delay = nextConversationMessageTimestampRefreshDelay(now); + if (delay === null) return; + const timer = globalThis.setTimeout(() => setNow(Date.now()), delay); + return () => globalThis.clearTimeout(timer); + }, [now]); + + const presentation = presentConversationMessageTimestamp(props.value, now, locale); + if (!presentation) return null; + + return ( + + + {presentation.absoluteLabel} + + ); +} From 22dacd3cc2633e7392489ddb443148ebb396b26f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:42:26 +0800 Subject: [PATCH 3/8] feat(desktop): reveal message timestamps on intent Generated-by: Codex --- .../e2e/message-timestamp-visibility.spec.ts | 68 +++++++++++++++++++ ...nversation-message-timestamp-style.test.ts | 42 ++++++++++++ packages/ui/src/styles.css | 43 ++++++------ 3 files changed, 131 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/e2e/message-timestamp-visibility.spec.ts create mode 100644 packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts diff --git a/apps/desktop/e2e/message-timestamp-visibility.spec.ts b/apps/desktop/e2e/message-timestamp-visibility.spec.ts new file mode 100644 index 0000000000..5fe891f279 --- /dev/null +++ b/apps/desktop/e2e/message-timestamp-visibility.spec.ts @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { COMPOSER_INPUT, expect, test } from './fixtures'; + +const PROMPT = 'timestamp hover probe'; + +function messageBox(page: import('@playwright/test').Page) { + return page.locator('.maka-user-message', { hasText: PROMPT }).last(); +} + +async function roundedBox(locator: import('@playwright/test').Locator) { + return locator.evaluate((element) => { + const box = element.getBoundingClientRect(); + return { + x: Math.round(box.x), + y: Math.round(box.y), + width: Math.round(box.width), + height: Math.round(box.height), + }; + }); +} + +test('reveals a same-day timestamp on hover and focus without moving the message', async ({ + window: page, +}) => { + const composer = page.locator(COMPOSER_INPUT); + await composer.fill(PROMPT); + await composer.press('Enter'); + await expect(page.getByText(/Fake backend received: timestamp hover probe/)).toBeVisible(); + + const message = messageBox(page); + const timestamp = message.locator('.maka-message-time-presentation'); + const visual = timestamp.locator('.maka-message-time-visual'); + await expect(timestamp).toHaveCSS('opacity', '0'); + await expect(visual).not.toContainText(/[年月日]|\b20\d{2}\b/); + const before = await roundedBox(message); + + await message.hover(); + await expect(timestamp).toHaveCSS('opacity', '1'); + expect(await roundedBox(message)).toEqual(before); + + await composer.hover(); + await expect(timestamp).toHaveCSS('opacity', '0'); + + const copyButton = message.getByRole('button', { + name: new RegExp(`复制消息:${PROMPT}`), + }); + await copyButton.focus(); + await expect(timestamp).toHaveCSS('opacity', '1'); + expect(await roundedBox(message)).toEqual(before); +}); diff --git a/packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts b/packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts new file mode 100644 index 0000000000..bfe6ec2836 --- /dev/null +++ b/packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const UI_SRC = resolve(import.meta.dirname, '..', '..', 'src'); + +test('message timestamps reserve layout and reveal only on message intent', async () => { + const css = (await readFile(resolve(UI_SRC, 'styles.css'), 'utf8')) + .replace(/\/\*[\s\S]*?\*\//g, ''); + const resting = /\.maka-message-time-presentation\s*\{([^}]*)\}/.exec(css); + assert.ok(resting, 'the timestamp presentation rule is missing'); + assert.match(resting[1], /display\s*:\s*inline-flex/); + assert.match(resting[1], /opacity\s*:\s*0/); + assert.doesNotMatch(resting[1], /display\s*:\s*none|visibility\s*:\s*hidden/); + + const reveal = new RegExp( + String.raw`\.maka-user-message:hover\s+\.maka-message-time-presentation\s*,\s*` + + String.raw`\.maka-user-message:focus-within\s+\.maka-message-time-presentation\s*\{([^}]*)\}`, + ).exec(css); + assert.ok(reveal, 'hover and focus-within must share the timestamp reveal rule'); + assert.match(reveal[1], /opacity\s*:\s*1/); +}); diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index 6a5517a3ae..b903d3b205 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -172,31 +172,30 @@ margin: 0; } -/* The meta row holds two different kinds of thing, so it gets two different - rules. The time is content — a fact about the message — and stays put; gating - it on hover is what hid it from touch and from assistive tech. The actions are - chrome and only surface on hover or focus. Astryx already splits these into a - `timestamp` and a `footer` slot; the gate belongs on the slot, not on the row - that contains both. - - The `· ` between the two slots is Astryx's own separator, rendered whenever - the timestamp has something to be separated FROM. It is dropped rather than - gated: the two slots already read as separate at rest, since one is text and - the other a row of controls that fades in beside it, so the bullet adds a mark - without adding a distinction. The slots carry no class of their own, so it is - addressed positionally — the timestamp is the first child span and the - separator the one right after it (the footer's own children are buttons). - - A transparent action still takes clicks, here and on the assistant footer. - `pointer-events: none` looks like the fix and is not obviously safe: it makes - the pointer fall through to the ancestor whose `:hover` is what restores the - button, so being clickable again depends on the browser redoing hit-testing - on a later mouse event. Continuous movement gets there; a jump that lands and - stops was not something this surface could be made to reproduce. Left as-is - rather than traded for a failure mode that could not be demonstrated. */ +/* Visual timestamps and message actions surface when the message has pointer + or keyboard intent. Opacity keeps the timestamp's geometry stable while its + full accessible label remains in the tree. Astryx's separator between the + timestamp and footer slots stays removed because the actions already read as + a separate control group. */ .maka-message-meta > span + span { display: none; } +.maka-message-time-presentation { + display: inline-flex; + align-items: center; + white-space: nowrap; + font-variant-numeric: tabular-nums; + opacity: 0; + transition: opacity var(--duration-quick) var(--ease-out-strong); +} +.maka-message-time-visual { + display: inline-flex; + align-items: center; +} +.maka-user-message:hover .maka-message-time-presentation, +.maka-user-message:focus-within .maka-message-time-presentation { + opacity: 1; +} .maka-message-meta .maka-turn-footer-action { opacity: 0; transition: opacity var(--duration-quick) var(--ease-out-strong); From d22f7e091f37d2583a0ebc3d35eb3f0502e37c41 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Mon, 24 Aug 2026 23:40:04 +0800 Subject: [PATCH 4/8] fix(ui): address message timestamp review Use one UI-locale formatter for the complete visible timestamp and share one midnight refresh across mounted messages. Generated-by: Codex --- .../src/renderer/styles/chat-message.css | 13 +- .../conversation-message-timestamp.test.ts | 16 +- .../src/conversation-message-timestamp.ts | 21 +-- .../conversation-message-timestamp.test.tsx | 137 +++++++++++++++++- .../ui/src/conversation-message-timestamp.tsx | 61 +++++--- 5 files changed, 185 insertions(+), 63 deletions(-) diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index 3df4aa0064..df2a81738d 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -261,9 +261,8 @@ Markdown; PR 8 owns the remaining streaming and scroll-anchoring boundary. */ /* --- User-turn meta row (relocated from tool-output.css) ----------------- */ -/* The time is an Astryx `Timestamp`, so the quiet register (supporting tier, - secondary colour) is the component's own default and is not restated here. - What stays is what the component does not decide: +/* The timestamp keeps the quiet register inherited from the message metadata + row. What stays here is what the localized formatter does not decide: - #1879: height off the control ruler, not off the line box. Measured then, bumping this element's leading to 40px took the box from 20px to 40px and @@ -271,10 +270,10 @@ leading already resolves to, so the pin is pixel-neutral today and load-bearing the next time a leading moves. Safe to pin because `nowrap` makes single-line a property of this box rather than an assumption. - - `tabular-nums`, because `Timestamp`'s `time` format renders the hour with - `hour: 'numeric'` — no leading zero, so `9:05` and `14:32` differ by a - digit. The row is right-aligned, so that difference shows up on the left - edge; equal-width digits keep it to the one character it really is. */ + - `tabular-nums`, because an hour with no leading zero makes `9:05` and + `14:32` differ by a digit. The row is right-aligned, so that difference + shows up on the left edge; equal-width digits keep it to the one character + it really is. */ .maka-message-time-inline { height: var(--h-control-xs); display: inline-flex; diff --git a/packages/core/src/__tests__/conversation-message-timestamp.test.ts b/packages/core/src/__tests__/conversation-message-timestamp.test.ts index 6afde7817f..525cfd49b2 100644 --- a/packages/core/src/__tests__/conversation-message-timestamp.test.ts +++ b/packages/core/src/__tests__/conversation-message-timestamp.test.ts @@ -33,8 +33,7 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'today'); - assert.equal(result.datePrefix, ''); - assert.equal(result.fallbackText, '14:30'); + assert.equal(result.visibleText, '14:30'); assert.equal(result.isoDateTime, new Date(timestamp).toISOString()); }); @@ -44,9 +43,8 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'same_year'); - assert.equal(result.datePrefix, '8月23日 '); - assert.equal(result.fallbackText, '8月23日 14:30'); - assert.doesNotMatch(result.fallbackText, /2026/); + assert.equal(result.visibleText, '8月23日 14:30'); + assert.doesNotMatch(result.visibleText, /2026/); }); it('adds the year when the local calendar years differ', () => { @@ -55,8 +53,7 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'other_year'); - assert.equal(result.datePrefix, '2025年8月23日 '); - assert.equal(result.fallbackText, '2025年8月23日 14:30'); + assert.equal(result.visibleText, '2025年8月23日 14:30'); assert.match(result.absoluteLabel, /2025/); }); @@ -66,9 +63,8 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'same_year'); - assert.match(result.fallbackText, /Aug 23/); - assert.doesNotMatch(result.fallbackText, /2026/); - assert.ok(result.datePrefix.length > 'Aug 23'.length); + assert.match(result.visibleText, /Aug 23/); + assert.doesNotMatch(result.visibleText, /2026/); }); it('uses calendar boundaries instead of elapsed 24-hour buckets', () => { diff --git a/packages/core/src/conversation-message-timestamp.ts b/packages/core/src/conversation-message-timestamp.ts index a10885711c..1f211acffd 100644 --- a/packages/core/src/conversation-message-timestamp.ts +++ b/packages/core/src/conversation-message-timestamp.ts @@ -23,8 +23,7 @@ export type ConversationMessageDateRelation = 'today' | 'same_year' | 'other_yea export interface ConversationMessageTimestampPresentation { relation: ConversationMessageDateRelation; - datePrefix: string; - fallbackText: string; + visibleText: string; absoluteLabel: string; isoDateTime: string; } @@ -51,20 +50,6 @@ function visibleFormatOptions( return { year: 'numeric', month: 'short', day: 'numeric', ...clock }; } -function prefixBeforeClock( - parts: Intl.DateTimeFormatPart[], - relation: ConversationMessageDateRelation, -): string { - if (relation === 'today') return ''; - const hourIndex = parts.findIndex((part) => part.type === 'hour'); - return hourIndex < 0 - ? '' - : parts - .slice(0, hourIndex) - .map((part) => part.value) - .join(''); -} - export function presentConversationMessageTimestamp( timestamp: number, now: number = Date.now(), @@ -78,12 +63,10 @@ export function presentConversationMessageTimestamp( const relation = localDateRelation(date, nowDate); const intlLocale = uiLocaleToIntlLocale(locale); const visibleFormatter = new Intl.DateTimeFormat(intlLocale, visibleFormatOptions(relation)); - const parts = visibleFormatter.formatToParts(date); return { relation, - datePrefix: prefixBeforeClock(parts, relation), - fallbackText: parts.map((part) => part.value).join(''), + visibleText: visibleFormatter.format(date), absoluteLabel: new Intl.DateTimeFormat(intlLocale, { dateStyle: 'medium', timeStyle: 'short', diff --git a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx index 4b13584d52..7a8152d2d5 100644 --- a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx +++ b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx @@ -58,7 +58,7 @@ function renderTimestamp(value: number, now: number) { } } -test('composes a date prefix with the unchanged Astryx time rendering', () => { +test('renders the complete visual timestamp from the UI locale', () => { const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); const value = new Date(2025, 7, 23, 14, 30, 0).getTime(); const document = renderTimestamp(value, now); @@ -66,11 +66,11 @@ test('composes a date prefix with the unchanged Astryx time rendering', () => { assert.ok(presentation); assert.equal(presentation.getAttribute('data-date-relation'), 'other_year'); - assert.equal( - presentation.querySelector('.maka-message-date-prefix')?.textContent, - '2025年8月23日 ', - ); - assert.ok(presentation.querySelector('time')); + const time = presentation.querySelector('time'); + assert.ok(time); + assert.equal(time.textContent, '2025年8月23日 14:30'); + assert.equal(time.getAttribute('dateTime'), new Date(value).toISOString()); + assert.equal(presentation.querySelector('.maka-message-date-prefix'), null); assert.equal(presentation.querySelector('[aria-hidden="true"]') !== null, true); assert.match( presentation.querySelector('.maka-visually-hidden')?.textContent ?? '', @@ -78,7 +78,7 @@ test('composes a date prefix with the unchanged Astryx time rendering', () => { ); }); -test('omits the visual date prefix for a message from today', () => { +test('renders only the UI-localized clock for a message from today', () => { const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); const value = new Date(2026, 7, 24, 14, 30, 0).getTime(); const document = renderTimestamp(value, now); @@ -87,7 +87,7 @@ test('omits the visual date prefix for a message from today', () => { document.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), 'today', ); - assert.equal(document.querySelector('.maka-message-date-prefix'), null); + assert.equal(document.querySelector('time')?.textContent, '14:30'); }); test('TurnView routes original and steering user timestamps through the adapter', () => { @@ -138,6 +138,127 @@ test('TurnView routes original and steering user timestamps through the adapter' } }); +test('shares one local-midnight timer across mounted timestamps', async () => { + const start = new Date(2026, 0, 15, 23, 59, 50).getTime(); + const originalNow = Date.now; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const midnightTimers = new Set>(); + let midnightTimerCount = 0; + let midnightTimerClearCount = 0; + Date.now = () => start; + globalThis.setTimeout = (( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + const timer = originalSetTimeout(callback, delay, ...args); + if (delay === 10_000) { + midnightTimerCount += 1; + midnightTimers.add(timer); + } + return timer; + }) as typeof globalThis.setTimeout; + globalThis.clearTimeout = ((timer: ReturnType) => { + if (midnightTimers.delete(timer)) midnightTimerClearCount += 1; + return originalClearTimeout(timer); + }) as typeof globalThis.clearTimeout; + + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + + try { + await act(() => { + root.render( + + + + , + ); + }); + assert.equal(midnightTimerCount, 1); + + await act(() => root.render(null)); + assert.equal(midnightTimerClearCount, 1); + + await act(() => { + root.render( + + + , + ); + }); + assert.equal(midnightTimerCount, 2); + } finally { + await act(() => root.unmount()); + Date.now = originalNow; + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + +test('rechecks the local day when subscription crosses midnight', async () => { + const beforeMidnight = new Date(2026, 0, 15, 23, 59, 59, 999).getTime(); + const afterMidnight = new Date(2026, 0, 16, 0, 0, 0).getTime(); + const originalNow = Date.now; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const midnightTimerSentinel = {} as ReturnType; + let currentNow = beforeMidnight; + Date.now = () => currentNow; + globalThis.setTimeout = (( + callback: (...args: unknown[]) => void, + delay?: number, + ...args: unknown[] + ) => { + if (delay === 1) { + currentNow = afterMidnight; + return midnightTimerSentinel; + } + return originalSetTimeout(callback, delay, ...args); + }) as typeof globalThis.setTimeout; + globalThis.clearTimeout = ((timer: ReturnType) => { + if (timer === midnightTimerSentinel) return; + return originalClearTimeout(timer); + }) as typeof globalThis.clearTimeout; + + const { document, window } = parseHTML('
'); + Object.assign(globalThis, { + document, + window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + + try { + await act(() => { + root.render( + + + , + ); + }); + assert.equal( + container.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), + 'same_year', + ); + } finally { + await act(() => root.unmount()); + Date.now = originalNow; + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } +}); + test('reclassifies a mounted timestamp after local midnight', async (context) => { const start = new Date(2026, 0, 15, 23, 59, 50).getTime(); const value = new Date(2026, 0, 15, 23, 59, 30).getTime(); diff --git a/packages/ui/src/conversation-message-timestamp.tsx b/packages/ui/src/conversation-message-timestamp.tsx index ede27780b7..ba50bbc4f5 100644 --- a/packages/ui/src/conversation-message-timestamp.tsx +++ b/packages/ui/src/conversation-message-timestamp.tsx @@ -17,26 +17,54 @@ * under the License. */ -import { useEffect, useState } from 'react'; -import { Timestamp } from '@astryxdesign/core'; +import { useSyncExternalStore } from 'react'; import { nextConversationMessageTimestampRefreshDelay, presentConversationMessageTimestamp, } from '@maka/core/conversation-message-timestamp'; import { useUiLocale } from './locale-context.js'; +let midnightRefreshTimer: ReturnType | undefined; +const midnightRefreshListeners = new Set<() => void>(); + +function scheduleMidnightRefresh() { + if (midnightRefreshTimer !== undefined || midnightRefreshListeners.size === 0) return; + const delay = nextConversationMessageTimestampRefreshDelay(); + if (delay === null) return; + midnightRefreshTimer = globalThis.setTimeout(() => { + midnightRefreshTimer = undefined; + for (const listener of midnightRefreshListeners) listener(); + scheduleMidnightRefresh(); + }, delay); +} + +function subscribeToMidnightRefresh(listener: () => void) { + midnightRefreshListeners.add(listener); + scheduleMidnightRefresh(); + return () => { + midnightRefreshListeners.delete(listener); + if (midnightRefreshListeners.size === 0 && midnightRefreshTimer !== undefined) { + globalThis.clearTimeout(midnightRefreshTimer); + midnightRefreshTimer = undefined; + } + }; +} + +function getLocalDayStart() { + const localDay = new Date(Date.now()); + localDay.setHours(0, 0, 0, 0); + return localDay.getTime(); +} + export function ConversationMessageTimestamp(props: { value: number }) { const locale = useUiLocale(); - const [now, setNow] = useState(() => Date.now()); - - useEffect(() => { - const delay = nextConversationMessageTimestampRefreshDelay(now); - if (delay === null) return; - const timer = globalThis.setTimeout(() => setNow(Date.now()), delay); - return () => globalThis.clearTimeout(timer); - }, [now]); + const localDayStart = useSyncExternalStore( + subscribeToMidnightRefresh, + getLocalDayStart, + getLocalDayStart, + ); - const presentation = presentConversationMessageTimestamp(props.value, now, locale); + const presentation = presentConversationMessageTimestamp(props.value, localDayStart, locale); if (!presentation) return null; return ( @@ -45,14 +73,9 @@ export function ConversationMessageTimestamp(props: { value: number }) { data-date-relation={presentation.relation} > {presentation.absoluteLabel} From 3f0993bb54f959313bbe92e28eb8e53a39eed02d Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:24:16 +0800 Subject: [PATCH 5/8] chore(ui): refresh Astryx surface inventory Generated-by: Codex --- docs/astryx-surface-file-inventory.md | 3 ++- docs/astryx-surface-file-inventory.paths | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index e602268358..089564ec7c 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 206 files — blocker 0, polish 0, aligned 206. +**Totals:** 207 files — blocker 0, polish 0, aligned 207. ## Exclusions (explicit) @@ -188,6 +188,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | IconButton, List, ListItem | aligned — uses Astryx (IconButton, List, ListItem) | aligned | | `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, IconButton, Lightbox, Token, Tooltip) | aligned | +| `packages/ui/src/conversation-message-timestamp.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned | | `packages/ui/src/icons.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/inline-reference.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index fc5720ad95..7a1a7e496b 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -160,6 +160,7 @@ packages/ui/src/chat-view.tsx packages/ui/src/components.tsx packages/ui/src/composer-message-queue.tsx packages/ui/src/composer.tsx +packages/ui/src/conversation-message-timestamp.tsx packages/ui/src/daily-review-panel.tsx packages/ui/src/icons.tsx packages/ui/src/inline-reference.tsx From 888301ad10f531cf68950ab3116becb015422b28 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:50:06 +0800 Subject: [PATCH 6/8] fix(ui): preserve host hour cycle for message timestamps --- .../conversation-message-timestamp.test.ts | 61 ++++++++++++++++++- .../src/conversation-message-timestamp.ts | 53 ++++++++++++++-- .../conversation-message-timestamp.test.tsx | 26 +++++++- packages/ui/src/chat-display-helpers.ts | 24 +++----- 4 files changed, 137 insertions(+), 27 deletions(-) diff --git a/packages/core/src/__tests__/conversation-message-timestamp.test.ts b/packages/core/src/__tests__/conversation-message-timestamp.test.ts index 525cfd49b2..c2ed9f07ec 100644 --- a/packages/core/src/__tests__/conversation-message-timestamp.test.ts +++ b/packages/core/src/__tests__/conversation-message-timestamp.test.ts @@ -25,6 +25,28 @@ import { } from '../conversation-message-timestamp.js'; const TODAY_NOW = new Date(2026, 7, 24, 20, 0, 0).getTime(); +const HOST_HOUR_CYCLE = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions() + .hourCycle; + +function expectedChineseTimestamp( + timestamp: number, + relation: 'today' | 'same_year' | 'other_year', +): string { + const options: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + ...(HOST_HOUR_CYCLE === undefined ? {} : { hourCycle: HOST_HOUR_CYCLE }), + }; + if (relation === 'same_year') { + options.month = 'short'; + options.day = 'numeric'; + } else if (relation === 'other_year') { + options.year = 'numeric'; + options.month = 'short'; + options.day = 'numeric'; + } + return new Intl.DateTimeFormat('zh-CN', options).format(new Date(timestamp)); +} describe('conversation message timestamp presentation', () => { it('shows only the clock for a message on the same local calendar day', () => { @@ -33,7 +55,7 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'today'); - assert.equal(result.visibleText, '14:30'); + assert.equal(result.visibleText, expectedChineseTimestamp(timestamp, 'today')); assert.equal(result.isoDateTime, new Date(timestamp).toISOString()); }); @@ -43,7 +65,7 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'same_year'); - assert.equal(result.visibleText, '8月23日 14:30'); + assert.equal(result.visibleText, expectedChineseTimestamp(timestamp, 'same_year')); assert.doesNotMatch(result.visibleText, /2026/); }); @@ -53,7 +75,7 @@ describe('conversation message timestamp presentation', () => { assert.ok(result); assert.equal(result.relation, 'other_year'); - assert.equal(result.visibleText, '2025年8月23日 14:30'); + assert.equal(result.visibleText, expectedChineseTimestamp(timestamp, 'other_year')); assert.match(result.absoluteLabel, /2025/); }); @@ -67,6 +89,39 @@ describe('conversation message timestamp presentation', () => { assert.doesNotMatch(result.visibleText, /2026/); }); + it('preserves the host hour cycle for visible and absolute timestamp text', () => { + const originalDateTimeFormat = Intl.DateTimeFormat; + function hostPreferenceDateTimeFormat( + locales?: Intl.LocalesArgument, + options?: Intl.DateTimeFormatOptions, + ): Intl.DateTimeFormat { + if (locales === undefined && options?.hour === 'numeric' && options.minute === undefined) { + return new originalDateTimeFormat('en-GB', options); + } + return new originalDateTimeFormat(locales, options); + } + + Object.defineProperty(Intl, 'DateTimeFormat', { + configurable: true, + value: hostPreferenceDateTimeFormat, + writable: true, + }); + try { + const timestamp = new Date(2026, 7, 24, 14, 30, 0).getTime(); + const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'en'); + + assert.ok(result); + assert.equal(result.visibleText, '14:30'); + assert.match(result.absoluteLabel, /14:30/); + } finally { + Object.defineProperty(Intl, 'DateTimeFormat', { + configurable: true, + value: originalDateTimeFormat, + writable: true, + }); + } + }); + it('uses calendar boundaries instead of elapsed 24-hour buckets', () => { const beforeMidnight = new Date(2026, 7, 23, 23, 59, 0).getTime(); const afterMidnight = new Date(2026, 7, 24, 0, 1, 0).getTime(); diff --git a/packages/core/src/conversation-message-timestamp.ts b/packages/core/src/conversation-message-timestamp.ts index 1f211acffd..7935e9549f 100644 --- a/packages/core/src/conversation-message-timestamp.ts +++ b/packages/core/src/conversation-message-timestamp.ts @@ -28,6 +28,8 @@ export interface ConversationMessageTimestampPresentation { isoDateTime: string; } +type HourCycle = Intl.DateTimeFormatOptions['hourCycle']; + function localDateRelation(date: Date, now: Date): ConversationMessageDateRelation { if ( date.getFullYear() === now.getFullYear() && @@ -39,10 +41,22 @@ function localDateRelation(date: Date, now: Date): ConversationMessageDateRelati return date.getFullYear() === now.getFullYear() ? 'same_year' : 'other_year'; } +function hostHourCycle(): HourCycle | undefined { + if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') { + return undefined; + } + return new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hourCycle; +} + function visibleFormatOptions( relation: ConversationMessageDateRelation, + hourCycle: HourCycle | undefined, ): Intl.DateTimeFormatOptions { - const clock: Intl.DateTimeFormatOptions = { hour: 'numeric', minute: '2-digit' }; + const clock: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + ...(hourCycle === undefined ? {} : { hourCycle }), + }; if (relation === 'today') return clock; if (relation === 'same_year') { return { month: 'short', day: 'numeric', ...clock }; @@ -50,6 +64,32 @@ function visibleFormatOptions( return { year: 'numeric', month: 'short', day: 'numeric', ...clock }; } +function formatAbsoluteTimestamp( + date: Date, + intlLocale: string, + hourCycle: HourCycle | undefined, +): string { + if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') { + return date.toISOString(); + } + return new Intl.DateTimeFormat(intlLocale, { + dateStyle: 'medium', + timeStyle: 'short', + ...(hourCycle === undefined ? {} : { hourCycle }), + }).format(date); +} + +export function formatConversationMessageAbsoluteTimestamp( + timestamp: number, + locale: UiLocale, +): string { + return formatAbsoluteTimestamp( + new Date(timestamp), + uiLocaleToIntlLocale(locale), + hostHourCycle(), + ); +} + export function presentConversationMessageTimestamp( timestamp: number, now: number = Date.now(), @@ -62,15 +102,16 @@ export function presentConversationMessageTimestamp( const relation = localDateRelation(date, nowDate); const intlLocale = uiLocaleToIntlLocale(locale); - const visibleFormatter = new Intl.DateTimeFormat(intlLocale, visibleFormatOptions(relation)); + const hourCycle = hostHourCycle(); + const visibleFormatter = new Intl.DateTimeFormat( + intlLocale, + visibleFormatOptions(relation, hourCycle), + ); return { relation, visibleText: visibleFormatter.format(date), - absoluteLabel: new Intl.DateTimeFormat(intlLocale, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(date), + absoluteLabel: formatAbsoluteTimestamp(date, intlLocale, hourCycle), isoDateTime: date.toISOString(), }; } diff --git a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx index 7a8152d2d5..20f8cdbbe7 100644 --- a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx +++ b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx @@ -35,6 +35,8 @@ const originalGlobals = { const originalActEnvironment = (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean; }).IS_REACT_ACT_ENVIRONMENT; +const HOST_HOUR_CYCLE = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }) + .resolvedOptions().hourCycle; afterEach(() => { Object.assign(globalThis, { @@ -58,6 +60,26 @@ function renderTimestamp(value: number, now: number) { } } +function expectedChineseTimestamp( + timestamp: number, + relation: 'today' | 'same_year' | 'other_year', +): string { + const options: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + ...(HOST_HOUR_CYCLE === undefined ? {} : { hourCycle: HOST_HOUR_CYCLE }), + }; + if (relation === 'same_year') { + options.month = 'short'; + options.day = 'numeric'; + } else if (relation === 'other_year') { + options.year = 'numeric'; + options.month = 'short'; + options.day = 'numeric'; + } + return new Intl.DateTimeFormat('zh-CN', options).format(new Date(timestamp)); +} + test('renders the complete visual timestamp from the UI locale', () => { const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); const value = new Date(2025, 7, 23, 14, 30, 0).getTime(); @@ -68,7 +90,7 @@ test('renders the complete visual timestamp from the UI locale', () => { assert.equal(presentation.getAttribute('data-date-relation'), 'other_year'); const time = presentation.querySelector('time'); assert.ok(time); - assert.equal(time.textContent, '2025年8月23日 14:30'); + assert.equal(time.textContent, expectedChineseTimestamp(value, 'other_year')); assert.equal(time.getAttribute('dateTime'), new Date(value).toISOString()); assert.equal(presentation.querySelector('.maka-message-date-prefix'), null); assert.equal(presentation.querySelector('[aria-hidden="true"]') !== null, true); @@ -87,7 +109,7 @@ test('renders only the UI-localized clock for a message from today', () => { document.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), 'today', ); - assert.equal(document.querySelector('time')?.textContent, '14:30'); + assert.equal(document.querySelector('time')?.textContent, expectedChineseTimestamp(value, 'today')); }); test('TurnView routes original and steering user timestamps through the adapter', () => { diff --git a/packages/ui/src/chat-display-helpers.ts b/packages/ui/src/chat-display-helpers.ts index 555cb4101a..769fd947ad 100644 --- a/packages/ui/src/chat-display-helpers.ts +++ b/packages/ui/src/chat-display-helpers.ts @@ -40,29 +40,21 @@ * sites. */ -import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; +import { + formatConversationMessageAbsoluteTimestamp, +} from '@maka/core/conversation-message-timestamp'; +import type { UiLocale } from '@maka/core/ui-locale'; import { getConversationCopy } from './conversation-copy.js'; -function createAbsoluteTimeFormat(locale: UiLocale): Intl.DateTimeFormat { - if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') { - return { format: (d: Date) => d.toISOString() } as unknown as Intl.DateTimeFormat; - } - return new Intl.DateTimeFormat( - uiLocaleToIntlLocale(locale), - { dateStyle: 'medium', timeStyle: 'short' }, - ); -} - export function formatAbsoluteTimestamp(ts: number, locale: UiLocale): string { - return createAbsoluteTimeFormat(locale).format(new Date(ts)); + return formatConversationMessageAbsoluteTimestamp(ts, locale); } /* `formatClockTime` (a 24-hour `HH:mm` for the user-message time) lived here until the meta row moved to Astryx's `Timestamp`. Locking the hour cycle was - the app overriding a preference that belongs to the reader's system, which is - why `Timestamp` formats against the host locale and offers no hour-cycle - knob. Absolute readings now come from that component, not from a local bag of - `Intl` options. */ + the app overriding a preference that belongs to the reader's system. Absolute + readings now share the conversation timestamp formatter's host-locale policy + instead of maintaining a local bag of `Intl` options. */ /** * A turn's duration, counted in whole seconds: `0s`, `25s`, `1m 54s`. From 0c82ed49f417cd76c827c775209a2b4c284f7a99 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:37:10 +0800 Subject: [PATCH 7/8] fix(ui): simplify contextual message timestamps Reuse Astryx auto formatting and remove the local calendar-tier rendering path. Generated-by: Codex --- .../e2e/message-timestamp-visibility.spec.ts | 68 ---- .../src/renderer/styles/chat-message.css | 13 +- docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 - packages/core/package.json | 1 - .../conversation-message-timestamp.test.ts | 147 -------- .../src/conversation-message-timestamp.ts | 128 ------- .../chat-turn-answer-identity.test.tsx | 16 + ...nversation-message-timestamp-style.test.ts | 42 --- .../conversation-message-timestamp.test.tsx | 320 ------------------ packages/ui/src/chat-display-helpers.ts | 24 +- packages/ui/src/chat-turn.tsx | 11 +- .../ui/src/conversation-message-timestamp.tsx | 83 ----- packages/ui/src/styles.css | 43 +-- 14 files changed, 69 insertions(+), 831 deletions(-) delete mode 100644 apps/desktop/e2e/message-timestamp-visibility.spec.ts delete mode 100644 packages/core/src/__tests__/conversation-message-timestamp.test.ts delete mode 100644 packages/core/src/conversation-message-timestamp.ts delete mode 100644 packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts delete mode 100644 packages/ui/src/__tests__/conversation-message-timestamp.test.tsx delete mode 100644 packages/ui/src/conversation-message-timestamp.tsx diff --git a/apps/desktop/e2e/message-timestamp-visibility.spec.ts b/apps/desktop/e2e/message-timestamp-visibility.spec.ts deleted file mode 100644 index 5fe891f279..0000000000 --- a/apps/desktop/e2e/message-timestamp-visibility.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { COMPOSER_INPUT, expect, test } from './fixtures'; - -const PROMPT = 'timestamp hover probe'; - -function messageBox(page: import('@playwright/test').Page) { - return page.locator('.maka-user-message', { hasText: PROMPT }).last(); -} - -async function roundedBox(locator: import('@playwright/test').Locator) { - return locator.evaluate((element) => { - const box = element.getBoundingClientRect(); - return { - x: Math.round(box.x), - y: Math.round(box.y), - width: Math.round(box.width), - height: Math.round(box.height), - }; - }); -} - -test('reveals a same-day timestamp on hover and focus without moving the message', async ({ - window: page, -}) => { - const composer = page.locator(COMPOSER_INPUT); - await composer.fill(PROMPT); - await composer.press('Enter'); - await expect(page.getByText(/Fake backend received: timestamp hover probe/)).toBeVisible(); - - const message = messageBox(page); - const timestamp = message.locator('.maka-message-time-presentation'); - const visual = timestamp.locator('.maka-message-time-visual'); - await expect(timestamp).toHaveCSS('opacity', '0'); - await expect(visual).not.toContainText(/[年月日]|\b20\d{2}\b/); - const before = await roundedBox(message); - - await message.hover(); - await expect(timestamp).toHaveCSS('opacity', '1'); - expect(await roundedBox(message)).toEqual(before); - - await composer.hover(); - await expect(timestamp).toHaveCSS('opacity', '0'); - - const copyButton = message.getByRole('button', { - name: new RegExp(`复制消息:${PROMPT}`), - }); - await copyButton.focus(); - await expect(timestamp).toHaveCSS('opacity', '1'); - expect(await roundedBox(message)).toEqual(before); -}); diff --git a/apps/desktop/src/renderer/styles/chat-message.css b/apps/desktop/src/renderer/styles/chat-message.css index df2a81738d..3df4aa0064 100644 --- a/apps/desktop/src/renderer/styles/chat-message.css +++ b/apps/desktop/src/renderer/styles/chat-message.css @@ -261,8 +261,9 @@ Markdown; PR 8 owns the remaining streaming and scroll-anchoring boundary. */ /* --- User-turn meta row (relocated from tool-output.css) ----------------- */ -/* The timestamp keeps the quiet register inherited from the message metadata - row. What stays here is what the localized formatter does not decide: +/* The time is an Astryx `Timestamp`, so the quiet register (supporting tier, + secondary colour) is the component's own default and is not restated here. + What stays is what the component does not decide: - #1879: height off the control ruler, not off the line box. Measured then, bumping this element's leading to 40px took the box from 20px to 40px and @@ -270,10 +271,10 @@ leading already resolves to, so the pin is pixel-neutral today and load-bearing the next time a leading moves. Safe to pin because `nowrap` makes single-line a property of this box rather than an assumption. - - `tabular-nums`, because an hour with no leading zero makes `9:05` and - `14:32` differ by a digit. The row is right-aligned, so that difference - shows up on the left edge; equal-width digits keep it to the one character - it really is. */ + - `tabular-nums`, because `Timestamp`'s `time` format renders the hour with + `hour: 'numeric'` — no leading zero, so `9:05` and `14:32` differ by a + digit. The row is right-aligned, so that difference shows up on the left + edge; equal-width digits keep it to the one character it really is. */ .maka-message-time-inline { height: var(--h-control-xs); display: inline-flex; diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index dd1bf1c4b5..acfdde61e2 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -5,7 +5,7 @@ Each row is one on-disk product surface file. Regenerated inventory must stay in Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 214 files — blocker 0, polish 1, aligned 213. +**Totals:** 213 files — blocker 0, polish 1, aligned 212. ## Exclusions (explicit) @@ -195,7 +195,6 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/components.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/composer-message-queue.tsx` | shell-chrome-or-panel | Button, IconButton, List, ListItem | raw ` { - it('shows only the clock for a message on the same local calendar day', () => { - const timestamp = new Date(2026, 7, 24, 14, 30, 0).getTime(); - const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'zh'); - - assert.ok(result); - assert.equal(result.relation, 'today'); - assert.equal(result.visibleText, expectedChineseTimestamp(timestamp, 'today')); - assert.equal(result.isoDateTime, new Date(timestamp).toISOString()); - }); - - it('adds month and day but omits the year across days in the same local year', () => { - const timestamp = new Date(2026, 7, 23, 14, 30, 0).getTime(); - const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'zh'); - - assert.ok(result); - assert.equal(result.relation, 'same_year'); - assert.equal(result.visibleText, expectedChineseTimestamp(timestamp, 'same_year')); - assert.doesNotMatch(result.visibleText, /2026/); - }); - - it('adds the year when the local calendar years differ', () => { - const timestamp = new Date(2025, 7, 23, 14, 30, 0).getTime(); - const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'zh'); - - assert.ok(result); - assert.equal(result.relation, 'other_year'); - assert.equal(result.visibleText, expectedChineseTimestamp(timestamp, 'other_year')); - assert.match(result.absoluteLabel, /2025/); - }); - - it('localizes English date punctuation without putting the current year back', () => { - const timestamp = new Date(2026, 7, 23, 14, 30, 0).getTime(); - const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'en'); - - assert.ok(result); - assert.equal(result.relation, 'same_year'); - assert.match(result.visibleText, /Aug 23/); - assert.doesNotMatch(result.visibleText, /2026/); - }); - - it('preserves the host hour cycle for visible and absolute timestamp text', () => { - const originalDateTimeFormat = Intl.DateTimeFormat; - function hostPreferenceDateTimeFormat( - locales?: Intl.LocalesArgument, - options?: Intl.DateTimeFormatOptions, - ): Intl.DateTimeFormat { - if (locales === undefined && options?.hour === 'numeric' && options.minute === undefined) { - return new originalDateTimeFormat('en-GB', options); - } - return new originalDateTimeFormat(locales, options); - } - - Object.defineProperty(Intl, 'DateTimeFormat', { - configurable: true, - value: hostPreferenceDateTimeFormat, - writable: true, - }); - try { - const timestamp = new Date(2026, 7, 24, 14, 30, 0).getTime(); - const result = presentConversationMessageTimestamp(timestamp, TODAY_NOW, 'en'); - - assert.ok(result); - assert.equal(result.visibleText, '14:30'); - assert.match(result.absoluteLabel, /14:30/); - } finally { - Object.defineProperty(Intl, 'DateTimeFormat', { - configurable: true, - value: originalDateTimeFormat, - writable: true, - }); - } - }); - - it('uses calendar boundaries instead of elapsed 24-hour buckets', () => { - const beforeMidnight = new Date(2026, 7, 23, 23, 59, 0).getTime(); - const afterMidnight = new Date(2026, 7, 24, 0, 1, 0).getTime(); - const result = presentConversationMessageTimestamp(beforeMidnight, afterMidnight, 'zh'); - - assert.ok(result); - assert.equal(result.relation, 'same_year'); - }); - - it('rejects invalid timestamps and invalid reference clocks', () => { - assert.equal(presentConversationMessageTimestamp(Number.NaN, TODAY_NOW, 'zh'), undefined); - assert.equal( - presentConversationMessageTimestamp(TODAY_NOW, Number.POSITIVE_INFINITY, 'zh'), - undefined, - ); - }); - - it('schedules the next refresh at the next local midnight', () => { - const now = new Date(2026, 0, 15, 23, 59, 30).getTime(); - assert.equal(nextConversationMessageTimestampRefreshDelay(now), 30_000); - assert.equal(nextConversationMessageTimestampRefreshDelay(Number.NaN), null); - }); -}); diff --git a/packages/core/src/conversation-message-timestamp.ts b/packages/core/src/conversation-message-timestamp.ts deleted file mode 100644 index 7935e9549f..0000000000 --- a/packages/core/src/conversation-message-timestamp.ts +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { uiLocaleToIntlLocale, type UiLocale } from './ui-locale.js'; - -export type ConversationMessageDateRelation = 'today' | 'same_year' | 'other_year'; - -export interface ConversationMessageTimestampPresentation { - relation: ConversationMessageDateRelation; - visibleText: string; - absoluteLabel: string; - isoDateTime: string; -} - -type HourCycle = Intl.DateTimeFormatOptions['hourCycle']; - -function localDateRelation(date: Date, now: Date): ConversationMessageDateRelation { - if ( - date.getFullYear() === now.getFullYear() && - date.getMonth() === now.getMonth() && - date.getDate() === now.getDate() - ) { - return 'today'; - } - return date.getFullYear() === now.getFullYear() ? 'same_year' : 'other_year'; -} - -function hostHourCycle(): HourCycle | undefined { - if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') { - return undefined; - } - return new Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hourCycle; -} - -function visibleFormatOptions( - relation: ConversationMessageDateRelation, - hourCycle: HourCycle | undefined, -): Intl.DateTimeFormatOptions { - const clock: Intl.DateTimeFormatOptions = { - hour: 'numeric', - minute: '2-digit', - ...(hourCycle === undefined ? {} : { hourCycle }), - }; - if (relation === 'today') return clock; - if (relation === 'same_year') { - return { month: 'short', day: 'numeric', ...clock }; - } - return { year: 'numeric', month: 'short', day: 'numeric', ...clock }; -} - -function formatAbsoluteTimestamp( - date: Date, - intlLocale: string, - hourCycle: HourCycle | undefined, -): string { - if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') { - return date.toISOString(); - } - return new Intl.DateTimeFormat(intlLocale, { - dateStyle: 'medium', - timeStyle: 'short', - ...(hourCycle === undefined ? {} : { hourCycle }), - }).format(date); -} - -export function formatConversationMessageAbsoluteTimestamp( - timestamp: number, - locale: UiLocale, -): string { - return formatAbsoluteTimestamp( - new Date(timestamp), - uiLocaleToIntlLocale(locale), - hostHourCycle(), - ); -} - -export function presentConversationMessageTimestamp( - timestamp: number, - now: number = Date.now(), - locale: UiLocale = 'zh', -): ConversationMessageTimestampPresentation | undefined { - if (!Number.isFinite(timestamp) || !Number.isFinite(now)) return undefined; - const date = new Date(timestamp); - const nowDate = new Date(now); - if (Number.isNaN(date.getTime()) || Number.isNaN(nowDate.getTime())) return undefined; - - const relation = localDateRelation(date, nowDate); - const intlLocale = uiLocaleToIntlLocale(locale); - const hourCycle = hostHourCycle(); - const visibleFormatter = new Intl.DateTimeFormat( - intlLocale, - visibleFormatOptions(relation, hourCycle), - ); - - return { - relation, - visibleText: visibleFormatter.format(date), - absoluteLabel: formatAbsoluteTimestamp(date, intlLocale, hourCycle), - isoDateTime: date.toISOString(), - }; -} - -export function nextConversationMessageTimestampRefreshDelay( - now: number = Date.now(), -): number | null { - if (!Number.isFinite(now)) return null; - const nextMidnight = new Date(now); - if (Number.isNaN(nextMidnight.getTime())) return null; - nextMidnight.setHours(24, 0, 0, 0); - const delay = nextMidnight.getTime() - now; - return Number.isFinite(delay) && delay > 0 ? delay : null; -} diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index f0ac7cec5b..a618d403b7 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -229,6 +229,22 @@ test('uses human conversation context instead of raw ids in action names', async ); }); +test('uses Astryx auto formatting for user-message timestamps', async () => { + const { container, root } = domRoot(); + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1_000; + const turn = { + ...turnWith([{ ...ANSWER, live: false }]), + user: { id: 'ask', role: 'user' as const, text: 'ask', ts: twoHoursAgo }, + }; + + await renderTurn(root, turn); + + const timestamp = container.querySelector('.maka-message-time-inline time'); + assert.ok(timestamp, 'Astryx Timestamp renders the semantic time element'); + assert.match(timestamp.textContent ?? '', /2 hours ago/); + assert.equal(timestamp.getAttribute('tabindex'), '0', 'the absolute-time hover card is keyboard reachable'); +}); + /** * The live handoff announces itself exactly once, when the answer enters its * settled phase. A bubble replayed from history mounts already past the diff --git a/packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts b/packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts deleted file mode 100644 index bfe6ec2836..0000000000 --- a/packages/ui/src/__tests__/conversation-message-timestamp-style.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; -import test from 'node:test'; - -const UI_SRC = resolve(import.meta.dirname, '..', '..', 'src'); - -test('message timestamps reserve layout and reveal only on message intent', async () => { - const css = (await readFile(resolve(UI_SRC, 'styles.css'), 'utf8')) - .replace(/\/\*[\s\S]*?\*\//g, ''); - const resting = /\.maka-message-time-presentation\s*\{([^}]*)\}/.exec(css); - assert.ok(resting, 'the timestamp presentation rule is missing'); - assert.match(resting[1], /display\s*:\s*inline-flex/); - assert.match(resting[1], /opacity\s*:\s*0/); - assert.doesNotMatch(resting[1], /display\s*:\s*none|visibility\s*:\s*hidden/); - - const reveal = new RegExp( - String.raw`\.maka-user-message:hover\s+\.maka-message-time-presentation\s*,\s*` + - String.raw`\.maka-user-message:focus-within\s+\.maka-message-time-presentation\s*\{([^}]*)\}`, - ).exec(css); - assert.ok(reveal, 'hover and focus-within must share the timestamp reveal rule'); - assert.match(reveal[1], /opacity\s*:\s*1/); -}); diff --git a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx b/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx deleted file mode 100644 index 20f8cdbbe7..0000000000 --- a/packages/ui/src/__tests__/conversation-message-timestamp.test.tsx +++ /dev/null @@ -1,320 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import assert from 'node:assert/strict'; -import { afterEach, test } from 'node:test'; -import { act } from 'react'; -import { createRoot } from 'react-dom/client'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { parseHTML } from 'linkedom'; -import { ConversationMessageTimestamp } from '../conversation-message-timestamp.js'; -import { TurnView } from '../chat-turn.js'; -import { LocaleProvider } from '../locale-context.js'; -import type { TurnViewModel } from '../materialize.js'; - -const originalGlobals = { - document: globalThis.document, - window: globalThis.window, -}; -const originalActEnvironment = (globalThis as typeof globalThis & { - IS_REACT_ACT_ENVIRONMENT?: boolean; -}).IS_REACT_ACT_ENVIRONMENT; -const HOST_HOUR_CYCLE = new Intl.DateTimeFormat(undefined, { hour: 'numeric' }) - .resolvedOptions().hourCycle; - -afterEach(() => { - Object.assign(globalThis, { - ...originalGlobals, - IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, - }); -}); - -function renderTimestamp(value: number, now: number) { - const originalNow = Date.now; - Date.now = () => now; - try { - const markup = renderToStaticMarkup( - - - , - ); - return parseHTML(`${markup}`).document; - } finally { - Date.now = originalNow; - } -} - -function expectedChineseTimestamp( - timestamp: number, - relation: 'today' | 'same_year' | 'other_year', -): string { - const options: Intl.DateTimeFormatOptions = { - hour: 'numeric', - minute: '2-digit', - ...(HOST_HOUR_CYCLE === undefined ? {} : { hourCycle: HOST_HOUR_CYCLE }), - }; - if (relation === 'same_year') { - options.month = 'short'; - options.day = 'numeric'; - } else if (relation === 'other_year') { - options.year = 'numeric'; - options.month = 'short'; - options.day = 'numeric'; - } - return new Intl.DateTimeFormat('zh-CN', options).format(new Date(timestamp)); -} - -test('renders the complete visual timestamp from the UI locale', () => { - const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); - const value = new Date(2025, 7, 23, 14, 30, 0).getTime(); - const document = renderTimestamp(value, now); - const presentation = document.querySelector('.maka-message-time-presentation'); - - assert.ok(presentation); - assert.equal(presentation.getAttribute('data-date-relation'), 'other_year'); - const time = presentation.querySelector('time'); - assert.ok(time); - assert.equal(time.textContent, expectedChineseTimestamp(value, 'other_year')); - assert.equal(time.getAttribute('dateTime'), new Date(value).toISOString()); - assert.equal(presentation.querySelector('.maka-message-date-prefix'), null); - assert.equal(presentation.querySelector('[aria-hidden="true"]') !== null, true); - assert.match( - presentation.querySelector('.maka-visually-hidden')?.textContent ?? '', - /2025/, - ); -}); - -test('renders only the UI-localized clock for a message from today', () => { - const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); - const value = new Date(2026, 7, 24, 14, 30, 0).getTime(); - const document = renderTimestamp(value, now); - - assert.equal( - document.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), - 'today', - ); - assert.equal(document.querySelector('time')?.textContent, expectedChineseTimestamp(value, 'today')); -}); - -test('TurnView routes original and steering user timestamps through the adapter', () => { - const now = new Date(2026, 7, 24, 20, 0, 0).getTime(); - const originalNow = Date.now; - Date.now = () => now; - try { - const turn: TurnViewModel = { - turnId: 'turn-1', - status: 'completed', - partialOutputRetained: false, - user: { - id: 'original', - role: 'user', - text: 'original request', - ts: new Date(2026, 7, 24, 14, 30, 0).getTime(), - }, - tools: [], - notes: [], - startedAt: now, - timeline: [ - { - kind: 'user', - message: { - id: 'steer-1', - role: 'user', - text: 'steering request', - ts: new Date(2025, 7, 23, 14, 30, 0).getTime(), - }, - messageId: 'steer-1', - }, - ], - }; - const markup = renderToStaticMarkup( - - - , - ); - const document = parseHTML(`${markup}`).document; - assert.deepEqual( - [...document.querySelectorAll('.maka-message-time-presentation')].map((node) => - node.getAttribute('data-date-relation'), - ), - ['today', 'other_year'], - ); - } finally { - Date.now = originalNow; - } -}); - -test('shares one local-midnight timer across mounted timestamps', async () => { - const start = new Date(2026, 0, 15, 23, 59, 50).getTime(); - const originalNow = Date.now; - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - const midnightTimers = new Set>(); - let midnightTimerCount = 0; - let midnightTimerClearCount = 0; - Date.now = () => start; - globalThis.setTimeout = (( - callback: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => { - const timer = originalSetTimeout(callback, delay, ...args); - if (delay === 10_000) { - midnightTimerCount += 1; - midnightTimers.add(timer); - } - return timer; - }) as typeof globalThis.setTimeout; - globalThis.clearTimeout = ((timer: ReturnType) => { - if (midnightTimers.delete(timer)) midnightTimerClearCount += 1; - return originalClearTimeout(timer); - }) as typeof globalThis.clearTimeout; - - const { document, window } = parseHTML('
'); - Object.assign(globalThis, { - document, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - - try { - await act(() => { - root.render( - - - - , - ); - }); - assert.equal(midnightTimerCount, 1); - - await act(() => root.render(null)); - assert.equal(midnightTimerClearCount, 1); - - await act(() => { - root.render( - - - , - ); - }); - assert.equal(midnightTimerCount, 2); - } finally { - await act(() => root.unmount()); - Date.now = originalNow; - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - } -}); - -test('rechecks the local day when subscription crosses midnight', async () => { - const beforeMidnight = new Date(2026, 0, 15, 23, 59, 59, 999).getTime(); - const afterMidnight = new Date(2026, 0, 16, 0, 0, 0).getTime(); - const originalNow = Date.now; - const originalSetTimeout = globalThis.setTimeout; - const originalClearTimeout = globalThis.clearTimeout; - const midnightTimerSentinel = {} as ReturnType; - let currentNow = beforeMidnight; - Date.now = () => currentNow; - globalThis.setTimeout = (( - callback: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => { - if (delay === 1) { - currentNow = afterMidnight; - return midnightTimerSentinel; - } - return originalSetTimeout(callback, delay, ...args); - }) as typeof globalThis.setTimeout; - globalThis.clearTimeout = ((timer: ReturnType) => { - if (timer === midnightTimerSentinel) return; - return originalClearTimeout(timer); - }) as typeof globalThis.clearTimeout; - - const { document, window } = parseHTML('
'); - Object.assign(globalThis, { - document, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - - try { - await act(() => { - root.render( - - - , - ); - }); - assert.equal( - container.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), - 'same_year', - ); - } finally { - await act(() => root.unmount()); - Date.now = originalNow; - globalThis.setTimeout = originalSetTimeout; - globalThis.clearTimeout = originalClearTimeout; - } -}); - -test('reclassifies a mounted timestamp after local midnight', async (context) => { - const start = new Date(2026, 0, 15, 23, 59, 50).getTime(); - const value = new Date(2026, 0, 15, 23, 59, 30).getTime(); - context.mock.timers.enable({ apis: ['Date', 'setTimeout'], now: start }); - const { document, window } = parseHTML('
'); - Object.assign(globalThis, { - document, - window, - IS_REACT_ACT_ENVIRONMENT: true, - }); - const container = document.querySelector('#root'); - assert.ok(container); - const root = createRoot(container); - - try { - await act(() => { - root.render( - - - , - ); - }); - assert.equal( - container.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), - 'today', - ); - - await act(() => context.mock.timers.tick(10_000)); - assert.equal( - container.querySelector('.maka-message-time-presentation')?.getAttribute('data-date-relation'), - 'same_year', - ); - } finally { - await act(() => root.unmount()); - context.mock.timers.reset(); - } -}); diff --git a/packages/ui/src/chat-display-helpers.ts b/packages/ui/src/chat-display-helpers.ts index 769fd947ad..555cb4101a 100644 --- a/packages/ui/src/chat-display-helpers.ts +++ b/packages/ui/src/chat-display-helpers.ts @@ -40,21 +40,29 @@ * sites. */ -import { - formatConversationMessageAbsoluteTimestamp, -} from '@maka/core/conversation-message-timestamp'; -import type { UiLocale } from '@maka/core/ui-locale'; +import { uiLocaleToIntlLocale, type UiLocale } from '@maka/core/ui-locale'; import { getConversationCopy } from './conversation-copy.js'; +function createAbsoluteTimeFormat(locale: UiLocale): Intl.DateTimeFormat { + if (typeof Intl === 'undefined' || typeof Intl.DateTimeFormat !== 'function') { + return { format: (d: Date) => d.toISOString() } as unknown as Intl.DateTimeFormat; + } + return new Intl.DateTimeFormat( + uiLocaleToIntlLocale(locale), + { dateStyle: 'medium', timeStyle: 'short' }, + ); +} + export function formatAbsoluteTimestamp(ts: number, locale: UiLocale): string { - return formatConversationMessageAbsoluteTimestamp(ts, locale); + return createAbsoluteTimeFormat(locale).format(new Date(ts)); } /* `formatClockTime` (a 24-hour `HH:mm` for the user-message time) lived here until the meta row moved to Astryx's `Timestamp`. Locking the hour cycle was - the app overriding a preference that belongs to the reader's system. Absolute - readings now share the conversation timestamp formatter's host-locale policy - instead of maintaining a local bag of `Intl` options. */ + the app overriding a preference that belongs to the reader's system, which is + why `Timestamp` formats against the host locale and offers no hour-cycle + knob. Absolute readings now come from that component, not from a local bag of + `Intl` options. */ /** * A turn's duration, counted in whole seconds: `0s`, `25s`, `1m 54s`. diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index 06aebf7751..c691936fb2 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -43,6 +43,7 @@ import { IconButton as UiIconButton, Spinner, Thumbnail, + Timestamp, Token, useLightbox, } from '@astryxdesign/core'; @@ -71,7 +72,6 @@ import { useUiLocale } from './locale-context.js'; import { getConversationCopy } from './conversation-copy.js'; import { AstryxLocaleProvider } from './astryx-i18n.js'; import { InlineReferenceText } from './inline-reference.js'; -import { ConversationMessageTimestamp } from './conversation-message-timestamp.js'; export function LocalizedChatMessage({ accessibleLabel, @@ -189,9 +189,12 @@ const UserMessageBody = memo(function UserMessageBody(props: { - : undefined + props.ts !== undefined ? ( + /* `value` takes ms directly: Timestamp's own parseValue reads + anything past 1e12 as milliseconds (2001-09-09 onward), and a + chat message never predates that. */ + () + ) : undefined } footer={ <> diff --git a/packages/ui/src/conversation-message-timestamp.tsx b/packages/ui/src/conversation-message-timestamp.tsx deleted file mode 100644 index ba50bbc4f5..0000000000 --- a/packages/ui/src/conversation-message-timestamp.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { useSyncExternalStore } from 'react'; -import { - nextConversationMessageTimestampRefreshDelay, - presentConversationMessageTimestamp, -} from '@maka/core/conversation-message-timestamp'; -import { useUiLocale } from './locale-context.js'; - -let midnightRefreshTimer: ReturnType | undefined; -const midnightRefreshListeners = new Set<() => void>(); - -function scheduleMidnightRefresh() { - if (midnightRefreshTimer !== undefined || midnightRefreshListeners.size === 0) return; - const delay = nextConversationMessageTimestampRefreshDelay(); - if (delay === null) return; - midnightRefreshTimer = globalThis.setTimeout(() => { - midnightRefreshTimer = undefined; - for (const listener of midnightRefreshListeners) listener(); - scheduleMidnightRefresh(); - }, delay); -} - -function subscribeToMidnightRefresh(listener: () => void) { - midnightRefreshListeners.add(listener); - scheduleMidnightRefresh(); - return () => { - midnightRefreshListeners.delete(listener); - if (midnightRefreshListeners.size === 0 && midnightRefreshTimer !== undefined) { - globalThis.clearTimeout(midnightRefreshTimer); - midnightRefreshTimer = undefined; - } - }; -} - -function getLocalDayStart() { - const localDay = new Date(Date.now()); - localDay.setHours(0, 0, 0, 0); - return localDay.getTime(); -} - -export function ConversationMessageTimestamp(props: { value: number }) { - const locale = useUiLocale(); - const localDayStart = useSyncExternalStore( - subscribeToMidnightRefresh, - getLocalDayStart, - getLocalDayStart, - ); - - const presentation = presentConversationMessageTimestamp(props.value, localDayStart, locale); - if (!presentation) return null; - - return ( - - - {presentation.absoluteLabel} - - ); -} diff --git a/packages/ui/src/styles.css b/packages/ui/src/styles.css index b903d3b205..6a5517a3ae 100644 --- a/packages/ui/src/styles.css +++ b/packages/ui/src/styles.css @@ -172,30 +172,31 @@ margin: 0; } -/* Visual timestamps and message actions surface when the message has pointer - or keyboard intent. Opacity keeps the timestamp's geometry stable while its - full accessible label remains in the tree. Astryx's separator between the - timestamp and footer slots stays removed because the actions already read as - a separate control group. */ +/* The meta row holds two different kinds of thing, so it gets two different + rules. The time is content — a fact about the message — and stays put; gating + it on hover is what hid it from touch and from assistive tech. The actions are + chrome and only surface on hover or focus. Astryx already splits these into a + `timestamp` and a `footer` slot; the gate belongs on the slot, not on the row + that contains both. + + The `· ` between the two slots is Astryx's own separator, rendered whenever + the timestamp has something to be separated FROM. It is dropped rather than + gated: the two slots already read as separate at rest, since one is text and + the other a row of controls that fades in beside it, so the bullet adds a mark + without adding a distinction. The slots carry no class of their own, so it is + addressed positionally — the timestamp is the first child span and the + separator the one right after it (the footer's own children are buttons). + + A transparent action still takes clicks, here and on the assistant footer. + `pointer-events: none` looks like the fix and is not obviously safe: it makes + the pointer fall through to the ancestor whose `:hover` is what restores the + button, so being clickable again depends on the browser redoing hit-testing + on a later mouse event. Continuous movement gets there; a jump that lands and + stops was not something this surface could be made to reproduce. Left as-is + rather than traded for a failure mode that could not be demonstrated. */ .maka-message-meta > span + span { display: none; } -.maka-message-time-presentation { - display: inline-flex; - align-items: center; - white-space: nowrap; - font-variant-numeric: tabular-nums; - opacity: 0; - transition: opacity var(--duration-quick) var(--ease-out-strong); -} -.maka-message-time-visual { - display: inline-flex; - align-items: center; -} -.maka-user-message:hover .maka-message-time-presentation, -.maka-user-message:focus-within .maka-message-time-presentation { - opacity: 1; -} .maka-message-meta .maka-turn-footer-action { opacity: 0; transition: opacity var(--duration-quick) var(--ease-out-strong); From b5ac0852b2d4549083026c4c353a4dba01a03896 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:45:24 +0800 Subject: [PATCH 8/8] fix(ui): keep relative message timestamps current Enable Astryx live updates for auto-formatted message timestamps and cover the label advancing as time passes. Generated-by: Codex --- .../ui/src/__tests__/chat-turn-answer-identity.test.tsx | 9 +++++++-- packages/ui/src/chat-turn.tsx | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx index a618d403b7..7255c315f6 100644 --- a/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx +++ b/packages/ui/src/__tests__/chat-turn-answer-identity.test.tsx @@ -229,9 +229,11 @@ test('uses human conversation context instead of raw ids in action names', async ); }); -test('uses Astryx auto formatting for user-message timestamps', async () => { +test('keeps Astryx auto formatting live for user-message timestamps', async (context) => { + const now = Date.UTC(2026, 7, 27, 12); + context.mock.timers.enable({ apis: ['Date', 'setInterval'], now }); const { container, root } = domRoot(); - const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1_000; + const twoHoursAgo = now - 2 * 60 * 60 * 1_000; const turn = { ...turnWith([{ ...ANSWER, live: false }]), user: { id: 'ask', role: 'user' as const, text: 'ask', ts: twoHoursAgo }, @@ -243,6 +245,9 @@ test('uses Astryx auto formatting for user-message timestamps', async () => { assert.ok(timestamp, 'Astryx Timestamp renders the semantic time element'); assert.match(timestamp.textContent ?? '', /2 hours ago/); assert.equal(timestamp.getAttribute('tabindex'), '0', 'the absolute-time hover card is keyboard reachable'); + + await act(() => context.mock.timers.tick(60 * 60 * 1_000)); + assert.match(timestamp.textContent ?? '', /3 hours ago/); }); /** diff --git a/packages/ui/src/chat-turn.tsx b/packages/ui/src/chat-turn.tsx index c691936fb2..f17bdb24fa 100644 --- a/packages/ui/src/chat-turn.tsx +++ b/packages/ui/src/chat-turn.tsx @@ -193,7 +193,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { /* `value` takes ms directly: Timestamp's own parseValue reads anything past 1e12 as milliseconds (2001-09-09 onward), and a chat message never predates that. */ - () + () ) : undefined } footer={