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.datePrefix ? (
+ {presentation.datePrefix}
+ ) : null}
+
+
+ {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.datePrefix ? (
- {presentation.datePrefix}
- ) : null}
-
+
{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 `