From 82378b6c4d105007c4559d849a6a41a4544ae354 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 21 Aug 2026 10:52:04 +0800 Subject: [PATCH 1/5] fix: keep relative timestamps on just now for the first minute Sidebar and other RelativeTime rows were counting seconds under one minute, which made the suffix flicker. Hold a single just-now label until the minute boundary, and only refresh then. Generated-by: Maka --- .../core/src/__tests__/relative-time.test.ts | 33 ++++++++++++++ packages/core/src/relative-time.ts | 45 ++++++++++--------- packages/ui/src/relative-time.tsx | 7 +-- 3 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/__tests__/relative-time.test.ts diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts new file mode 100644 index 0000000000..dc904a9484 --- /dev/null +++ b/packages/core/src/__tests__/relative-time.test.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { + formatCompactTimestamp, + formatRelativeTimestamp, + nextRelativeRefreshDelay, + resetRelativeTimeFormatters, +} from '../relative-time.js'; + +const NOW = Date.UTC(2026, 7, 21, 12, 0, 0); + +describe('relative timestamp labels', () => { + it('holds a just-now label for the whole first minute instead of counting seconds', () => { + resetRelativeTimeFormatters(); + + for (const ageMs of [0, 1_000, 30_000, 59_999]) { + assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'zh'), '刚刚'); + assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'en'), 'just now'); + assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'zh'), '刚刚'); + } + + assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'zh'), '1分钟前'); + assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago'); + }); + + it('waits until the just-now window ends instead of ticking every second', () => { + assert.equal(nextRelativeRefreshDelay(NOW, NOW), 60_000); + assert.equal(nextRelativeRefreshDelay(NOW - 30_000, NOW), 30_000); + assert.equal(nextRelativeRefreshDelay(NOW - 59_999, NOW), 1); + assert.equal(nextRelativeRefreshDelay(NOW + 5_000, NOW), 65_000); + assert.equal(nextRelativeRefreshDelay(NOW - 60_000, NOW), 60_000); + }); +}); diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index f3d6331e07..6f34b32c70 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -25,11 +25,11 @@ * sidebar / settings panels. * * The formatter is pure (takes an optional `now` so tests do not have - * to monkey-patch `Date.now`). Buckets are intentionally narrow at the - * short end (second / minute / hour) so users see "刚刚" become - * "1 分钟前" promptly, then widen to days. Anything older than ~7 days - * falls back to an absolute date so we do not produce misleadingly - * round numbers like "5 个月前". + * to monkey-patch `Date.now`). The first minute stays on a single + * just-now label so scan-level rows do not tick through "1秒钟前", + * "2秒钟前", …. After that the buckets widen from minute to hour to + * day. Anything older than ~7 days falls back to an absolute date so + * we do not produce misleadingly round numbers like "5 个月前". * * The threshold is a deliberate divergence from the previous * implementation, which used `.format(-Math.round(diffHours / 24), 'day')` @@ -38,11 +38,19 @@ * the locale date string. */ -import { uiLocaleToIntlLocale, type UiLocale } from './ui-locale.js'; +import { uiLocaleToIntlLocale, type UiCatalog, type UiLocale } from './ui-locale.js'; /** Maximum age (ms) that still gets a relative bucket. Older → absolute. */ const RELATIVE_HORIZON_MS = 7 * 24 * 60 * 60 * 1000; +/** Age below this stays on one just-now label instead of counting seconds. */ +const JUST_NOW_MS = 60_000; + +const JUST_NOW: UiCatalog = { + zh: '刚刚', + en: 'just now', +}; + let cachedRelativeFormat: Intl.RelativeTimeFormat | null = null; let cachedAbsoluteFormat: Intl.DateTimeFormat | null = null; let cachedLocale: string | null = null; @@ -71,13 +79,12 @@ function getAbsoluteFormat(uiLocale: UiLocale): Intl.DateTimeFormat { } /** - * Returns a localized relative label for `ts` (e.g. "1 分钟前", "1 hour ago") + * Returns a localized relative label for `ts` (e.g. "刚刚", "1 分钟前") * when within `RELATIVE_HORIZON_MS`, otherwise the absolute date string. * - * `now` is injectable so tests can pin a deterministic clock. Future - * timestamps (`ts > now`) are clamped to the smallest "刚刚" bucket — we - * don't want sidebar rows showing "in 2 minutes" when a tab's clock - * drifts. + * `now` is injectable so tests can pin a deterministic clock. The first + * minute — and future timestamps from clock skew — stay on one just-now + * label so rows do not count seconds or show "in 2 minutes". */ export function formatRelativeTimestamp( ts: number, @@ -85,18 +92,13 @@ export function formatRelativeTimestamp( locale: UiLocale = 'zh', ): string { const diffMs = now - ts; - if (diffMs < 0) { - // Clock skew or future-dated record. Snap to "刚刚". - return getRelativeFormat(locale).format(-1, 'second'); + if (diffMs < JUST_NOW_MS) { + return JUST_NOW[locale]; } if (diffMs > RELATIVE_HORIZON_MS) { return getAbsoluteFormat(locale).format(new Date(ts)); } const diffSeconds = Math.round(diffMs / 1000); - if (diffSeconds < 60) { - // Clamp to >=1 so we never produce "0 seconds ago". - return getRelativeFormat(locale).format(-Math.max(1, diffSeconds), 'second'); - } const diffMinutes = Math.round(diffSeconds / 60); if (diffMinutes < 60) return getRelativeFormat(locale).format(-diffMinutes, 'minute'); const diffHours = Math.round(diffMinutes / 60); @@ -176,13 +178,14 @@ export function resetRelativeTimeFormatters(): void { /** * Picks the next refresh delay (ms) for a relative timestamp. Used by * the React `` ticker so we re-render at the right - * cadence: every second for sub-minute, every minute for sub-hour, - * every 10 minutes after that. Past the horizon we never re-render. + * cadence: once when the just-now window ends, every minute for + * sub-hour, every 10 minutes after that. Past the horizon we never + * re-render. */ export function nextRelativeRefreshDelay(ts: number, now: number = Date.now()): number | null { const diffMs = now - ts; if (diffMs > RELATIVE_HORIZON_MS) return null; - if (diffMs < 60_000) return 1_000; + if (diffMs < JUST_NOW_MS) return JUST_NOW_MS - diffMs; if (diffMs < 60 * 60_000) return 60_000; return 10 * 60_000; } diff --git a/packages/ui/src/relative-time.tsx b/packages/ui/src/relative-time.tsx index aeee09aa5a..354f91e9a4 100644 --- a/packages/ui/src/relative-time.tsx +++ b/packages/ui/src/relative-time.tsx @@ -31,9 +31,10 @@ import { useUiLocale } from './locale-context.js'; * PR-RELATIVE-TIME-0: a self-refreshing relative-time label. Sidebar + * message rows stay correct even when the window has been open for * hours without re-rendering on their own. The tick cadence comes from - * `nextRelativeRefreshDelay` so we tick every second within the first - * minute, every minute within the first hour, then every 10 minutes; - * past the 7-day horizon we stop ticking and show the absolute date. + * `nextRelativeRefreshDelay` so we stay on the just-now label for the + * first minute, then tick every minute within the first hour, then every + * 10 minutes; past the 7-day horizon we stop ticking and show the + * absolute date. * * `variant="compact"` swaps the past-horizon fallback for a date-only label * ("6月20日"), which is what `formatCompactTimestamp` exists for: the wide From 7b4e96e7ead1301efc6c7b1896fc393f1961caca Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 22 Aug 2026 01:19:23 +0800 Subject: [PATCH 2/5] refactor(core,ui): trim verbose comments and drop brittle test assertions Generated-by: Maka --- .../core/src/__tests__/relative-time.test.ts | 6 +-- packages/core/src/relative-time.ts | 51 ++++++------------- packages/ui/src/relative-time.tsx | 18 ++----- 3 files changed, 22 insertions(+), 53 deletions(-) diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts index dc904a9484..077975b3fe 100644 --- a/packages/core/src/__tests__/relative-time.test.ts +++ b/packages/core/src/__tests__/relative-time.test.ts @@ -10,7 +10,7 @@ import { const NOW = Date.UTC(2026, 7, 21, 12, 0, 0); describe('relative timestamp labels', () => { - it('holds a just-now label for the whole first minute instead of counting seconds', () => { + it('holds a just-now label for the whole first minute, then switches to minutes', () => { resetRelativeTimeFormatters(); for (const ageMs of [0, 1_000, 30_000, 59_999]) { @@ -23,11 +23,9 @@ describe('relative timestamp labels', () => { assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago'); }); - it('waits until the just-now window ends instead of ticking every second', () => { + it('delays the ticker until the just-now window ends', () => { assert.equal(nextRelativeRefreshDelay(NOW, NOW), 60_000); assert.equal(nextRelativeRefreshDelay(NOW - 30_000, NOW), 30_000); - assert.equal(nextRelativeRefreshDelay(NOW - 59_999, NOW), 1); - assert.equal(nextRelativeRefreshDelay(NOW + 5_000, NOW), 65_000); assert.equal(nextRelativeRefreshDelay(NOW - 60_000, NOW), 60_000); }); }); diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index 6f34b32c70..fa090f096a 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -18,24 +18,11 @@ */ /** - * PR-RELATIVE-TIME-0: a single, locale-aware relative-time formatter - * shared by every Maka surface. The existing `formatRelativeTimestamp` - * sat inside `packages/ui/src/components.tsx` as a private helper, - * which meant it could not be unit-tested and could not be reused by - * sidebar / settings panels. - * - * The formatter is pure (takes an optional `now` so tests do not have - * to monkey-patch `Date.now`). The first minute stays on a single - * just-now label so scan-level rows do not tick through "1秒钟前", - * "2秒钟前", …. After that the buckets widen from minute to hour to - * day. Anything older than ~7 days falls back to an absolute date so - * we do not produce misleadingly round numbers like "5 个月前". - * - * The threshold is a deliberate divergence from the previous - * implementation, which used `.format(-Math.round(diffHours / 24), 'day')` - * for ALL timestamps older than a day and produced things like - * "300 天前" for messages a year old. That was strictly less useful than - * the locale date string. + * Locale-aware relative-time formatter shared across Maka surfaces. Pure + * (optional `now`) so tests can pin a clock. The first minute stays on one + * just-now label instead of counting seconds, then buckets widen from minute + * to hour to day; past ~7 days we fall back to an absolute date, which is more + * useful than a relative label like "300 天前". */ import { uiLocaleToIntlLocale, type UiCatalog, type UiLocale } from './ui-locale.js'; @@ -79,12 +66,9 @@ function getAbsoluteFormat(uiLocale: UiLocale): Intl.DateTimeFormat { } /** - * Returns a localized relative label for `ts` (e.g. "刚刚", "1 分钟前") - * when within `RELATIVE_HORIZON_MS`, otherwise the absolute date string. - * - * `now` is injectable so tests can pin a deterministic clock. The first - * minute — and future timestamps from clock skew — stay on one just-now - * label so rows do not count seconds or show "in 2 minutes". + * Localized relative label for `ts` within the 7-day horizon, otherwise the + * absolute date string. `now` is injectable so tests pin a deterministic clock; + * future timestamps (clock skew) snap to the just-now label. */ export function formatRelativeTimestamp( ts: number, @@ -136,13 +120,10 @@ function getCompactFormats(uiLocale: UiLocale): { } /** - * Compact variant for space-starved rows (sidebar session list): same - * relative buckets inside the 7-day horizon, then a DATE-ONLY label — - * "6月20日" within the current year, "2025年6月20日" across years. - * `formatRelativeTimestamp`'s medium-date + time fallback - * ("2026年6月20日 16:33") is right for wide surfaces but crushed the - * session title next to it to ~2 characters. Minute precision belongs - * in tooltips/detail surfaces, not scan-level list rows. + * Compact variant for space-starved rows (sidebar session list): same relative + * buckets inside the horizon, then a date-only label ("6月20日" within the + * current year, "2025年6月20日" across years) instead of the wide + * medium-date-plus-time fallback. */ export function formatCompactTimestamp( ts: number, @@ -176,11 +157,9 @@ export function resetRelativeTimeFormatters(): void { } /** - * Picks the next refresh delay (ms) for a relative timestamp. Used by - * the React `` ticker so we re-render at the right - * cadence: once when the just-now window ends, every minute for - * sub-hour, every 10 minutes after that. Past the horizon we never - * re-render. + * Next tick delay (ms) for the `` ticker: once when the just-now + * window ends, every minute for the first hour, then every 10 minutes; null + * past the horizon (never re-render). */ export function nextRelativeRefreshDelay(ts: number, now: number = Date.now()): number | null { const diffMs = now - ts; diff --git a/packages/ui/src/relative-time.tsx b/packages/ui/src/relative-time.tsx index 354f91e9a4..415c556677 100644 --- a/packages/ui/src/relative-time.tsx +++ b/packages/ui/src/relative-time.tsx @@ -28,19 +28,11 @@ import { cn } from './utils.js'; import { useUiLocale } from './locale-context.js'; /** - * PR-RELATIVE-TIME-0: a self-refreshing relative-time label. Sidebar + - * message rows stay correct even when the window has been open for - * hours without re-rendering on their own. The tick cadence comes from - * `nextRelativeRefreshDelay` so we stay on the just-now label for the - * first minute, then tick every minute within the first hour, then every - * 10 minutes; past the 7-day horizon we stop ticking and show the - * absolute date. - * - * `variant="compact"` swaps the past-horizon fallback for a date-only label - * ("6月20日"), which is what `formatCompactTimestamp` exists for: the wide - * medium-date-plus-time fallback crushes a 260px rail row's title. The ticker is - * the same either way — a visible timestamp inside a memoized row is exactly the - * case that goes stale without it, and the rail's rows are memoized. + * Self-refreshing relative-time label: stays on the just-now label for the + * first minute, then ticks every minute, then every 10 minutes (see + * `nextRelativeRefreshDelay`), stopping past the 7-day horizon to show the + * absolute date. `variant="compact"` uses the date-only fallback + * (`formatCompactTimestamp`) that fits space-starved sidebar rows. */ export function RelativeTime(props: { ts: number; From 88b860ffb37fda5a734c2d80314a26823aa79024 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sat, 22 Aug 2026 01:23:55 +0800 Subject: [PATCH 3/5] refactor(core): collapse redundant branch in formatCompactTimestamp Generated-by: Maka --- packages/core/src/relative-time.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index fa090f096a..deec4faf4d 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -131,10 +131,7 @@ export function formatCompactTimestamp( locale: UiLocale = 'zh', ): string { const diffMs = now - ts; - if (diffMs >= 0 && diffMs <= RELATIVE_HORIZON_MS) { - return formatRelativeTimestamp(ts, now, locale); - } - if (diffMs < 0) return formatRelativeTimestamp(ts, now, locale); + if (diffMs <= RELATIVE_HORIZON_MS) return formatRelativeTimestamp(ts, now, locale); const { sameYear, otherYear } = getCompactFormats(locale); const date = new Date(ts); const nowDate = new Date(now); From e65809320bfa740297a85c53c7c05496d5472b0a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 02:41:51 +0800 Subject: [PATCH 4/5] chore: add the ASF header to the relative-time tests The new test file is covered by check:asf-headers after #3397. Generated-by: Grok --- .../core/src/__tests__/relative-time.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts index 077975b3fe..317f50d957 100644 --- a/packages/core/src/__tests__/relative-time.test.ts +++ b/packages/core/src/__tests__/relative-time.test.ts @@ -1,3 +1,22 @@ +/* + * 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 { From a59cbb00a9c7cc107f31b6d1567d7bd25fb02786 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Sun, 23 Aug 2026 03:31:25 +0800 Subject: [PATCH 5/5] fix: bound relative-time refresh age --- .../core/src/__tests__/relative-time.test.ts | 25 +++++++++++++++++++ packages/core/src/relative-time.ts | 17 ++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts index 317f50d957..6b6b0588c9 100644 --- a/packages/core/src/__tests__/relative-time.test.ts +++ b/packages/core/src/__tests__/relative-time.test.ts @@ -27,6 +27,7 @@ import { } from '../relative-time.js'; const NOW = Date.UTC(2026, 7, 21, 12, 0, 0); +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; describe('relative timestamp labels', () => { it('holds a just-now label for the whole first minute, then switches to minutes', () => { @@ -47,4 +48,28 @@ describe('relative timestamp labels', () => { assert.equal(nextRelativeRefreshDelay(NOW - 30_000, NOW), 30_000); assert.equal(nextRelativeRefreshDelay(NOW - 60_000, NOW), 60_000); }); + + it('treats a finite future timestamp as just now and schedules recovery', () => { + const futureTs = NOW + THIRTY_DAYS_MS; + const delay = nextRelativeRefreshDelay(futureTs, NOW); + + assert.equal(delay, 60_000); + assert.equal(formatRelativeTimestamp(futureTs, NOW, 'en'), 'just now'); + assert.equal(formatCompactTimestamp(futureTs, NOW, 'en'), 'just now'); + }); + + it('keeps finite timestamp refresh delays within the scheduler bound', () => { + for (const ts of [ + NOW - 60_000, + NOW - 60 * 60_000, + NOW, + NOW + THIRTY_DAYS_MS, + Number.POSITIVE_INFINITY, + Number.NaN, + Number.NEGATIVE_INFINITY, + ]) { + const delay = nextRelativeRefreshDelay(ts, NOW); + assert.ok(delay === null || (Number.isFinite(delay) && delay > 0 && delay <= 10 * 60_000)); + } + }); }); diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index deec4faf4d..958af484b4 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -38,6 +38,11 @@ const JUST_NOW: UiCatalog = { en: 'just now', }; +/** Future timestamps are treated as age zero and therefore display as just now. */ +function relativeAgeMs(ts: number, now: number): number { + return Math.max(0, now - ts); +} + let cachedRelativeFormat: Intl.RelativeTimeFormat | null = null; let cachedAbsoluteFormat: Intl.DateTimeFormat | null = null; let cachedLocale: string | null = null; @@ -75,7 +80,7 @@ export function formatRelativeTimestamp( now: number = Date.now(), locale: UiLocale = 'zh', ): string { - const diffMs = now - ts; + const diffMs = relativeAgeMs(ts, now); if (diffMs < JUST_NOW_MS) { return JUST_NOW[locale]; } @@ -130,7 +135,7 @@ export function formatCompactTimestamp( now: number = Date.now(), locale: UiLocale = 'zh', ): string { - const diffMs = now - ts; + const diffMs = relativeAgeMs(ts, now); if (diffMs <= RELATIVE_HORIZON_MS) return formatRelativeTimestamp(ts, now, locale); const { sameYear, otherYear } = getCompactFormats(locale); const date = new Date(ts); @@ -159,9 +164,9 @@ export function resetRelativeTimeFormatters(): void { * past the horizon (never re-render). */ export function nextRelativeRefreshDelay(ts: number, now: number = Date.now()): number | null { - const diffMs = now - ts; - if (diffMs > RELATIVE_HORIZON_MS) return null; - if (diffMs < JUST_NOW_MS) return JUST_NOW_MS - diffMs; - if (diffMs < 60 * 60_000) return 60_000; + const ageMs = relativeAgeMs(ts, now); + if (ageMs > RELATIVE_HORIZON_MS) return null; + if (ageMs < JUST_NOW_MS) return JUST_NOW_MS - ageMs; + if (ageMs < 60 * 60_000) return 60_000; return 10 * 60_000; }