fix(i18n): format relative times with the configured UI language (#8662)

Eight renderer call sites built Intl.RelativeTimeFormat(undefined, ...), which
resolves to the OS locale, so relative timestamps rendered in Korean on a
Korean-locale machine even with the UI language set to English. Unlike
DateTimeFormat, RelativeTimeFormat emits language words, so it must follow the
UI language.

Rebased onto main: the formatter now resolves through getIntlLocale() (#12105)
rather than i18n.resolvedLanguage, so the synthetic plugin<hex> resource
language cannot reach the constructor and throw. GitHubItemDialog and
PullRequestPage no longer own their formatter — main moved it into
work-item-state-presentation, which is converted instead, along with the newly
added site there.

Co-authored-by: moseoh <azqazq195@gmail.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
moseoh
2026-08-04 02:57:08 -07:00
committed by Neil
co-authored by Jinjing Orca
parent c39972ba7e
commit 32d3016cd5
11 changed files with 116 additions and 124 deletions
@@ -48,6 +48,7 @@ import type {
} from '../../../shared/types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
type JiraIssueWorkspaceProps = {
issue: JiraIssue | null
@@ -56,22 +57,8 @@ type JiraIssueWorkspaceProps = {
sourceContext?: TaskSourceContext | null
}
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function formatRelativeTime(input: string): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return 'recently'
}
const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000)
if (Math.abs(diffMinutes) < 60) {
return relativeFormatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return relativeFormatter.format(diffHours, 'hour')
}
return relativeFormatter.format(Math.round(diffHours / 24), 'day')
return formatUiRelativeTimeFromDate(input)
}
function buildJiraBranchName(issue: JiraIssue): string {
@@ -50,6 +50,7 @@ import {
linearUpdateIssue
} from '@/runtime/runtime-linear-client'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
function LinearIcon({ className }: { className?: string }): React.JSX.Element {
return (
@@ -101,22 +102,7 @@ function LinearEditChipAdornment({
}
function formatRelativeTime(input: string): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return 'recently'
}
const diffMs = date.getTime() - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (Math.abs(diffMinutes) < 60) {
return formatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return formatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return formatter.format(diffDays, 'day')
return formatUiRelativeTimeFromDate(input)
}
type LinearItemDrawerProps = {
+2 -22
View File
@@ -359,6 +359,7 @@ import {
resolveVisibleTaskProvider
} from '../../../shared/task-providers'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
import {
getGitHubModeButtons,
getGitHubTaskKindPresets,
@@ -585,29 +586,8 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string {
return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}`
}
// Why: Intl.RelativeTimeFormat allocation is non-trivial; hoist to module scope so all rows share one instance instead of allocating per render.
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function formatRelativeTime(input: string): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return 'recently'
}
const diffMs = date.getTime() - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return relativeTimeFormatter.format(diffDays, 'day')
return formatUiRelativeTimeFromDate(input)
}
type LinearProjectTab = 'overview' | 'issues'
@@ -73,6 +73,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id'
import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text'
import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTime } from '@/i18n/relative-time-format'
import {
getActivityThreadTaskTitle,
getActivityThreadWorkspaceTitle,
@@ -168,24 +169,12 @@ const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, {
minute: '2-digit'
})
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
function formatAbsoluteDate(timestamp: number): string {
return absoluteDateFormatter.format(new Date(timestamp))
}
function formatRelativeTime(timestamp: number): string {
const diffMs = timestamp - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return relativeTimeFormatter.format(diffDays, 'day')
return formatUiRelativeTime(timestamp - Date.now())
}
function findActivityTerminalPane(
@@ -1,24 +1,10 @@
import React from 'react'
import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types'
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
export function formatRelativeTime(input: string): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return 'recently'
}
const diffMs = date.getTime() - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
if (Math.abs(diffMinutes) < 60) {
return formatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return formatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return formatter.format(diffDays, 'day')
return formatUiRelativeTimeFromDate(input)
}
export function getStateLabel(item: GitHubWorkItem): string {
@@ -1,25 +1,10 @@
import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name'
import type { LinearIssue } from '../../../shared/types'
import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source'
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format'
export function formatLinearIssueRelativeTime(input: string): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return 'recently'
}
const diffMs = date.getTime() - Date.now()
const diffMinutes = Math.round(diffMs / 60_000)
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return relativeTimeFormatter.format(diffDays, 'day')
return formatUiRelativeTimeFromDate(input)
}
export function buildLinearIssueBranchName(issue: LinearIssue): string {
@@ -81,6 +81,7 @@ import {
type ProviderAccountRuntimeView
} from './provider-account-visibility'
import { translate } from '@/i18n/i18n'
import { formatUiRelativeTime } from '@/i18n/relative-time-format'
import { cn } from '@/lib/utils'
import { isWebClientLocation } from '@/lib/web-client-location'
import {
@@ -104,16 +105,7 @@ function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string {
if (diffMs < 60_000) {
return translate('auto.components.settings.AccountsPane.3a30aaf526', 'just now')
}
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
const minutes = Math.round(diffMs / 60_000)
if (minutes < 60) {
return formatter.format(-minutes, 'minute')
}
const hours = Math.round(minutes / 60)
if (hours < 24) {
return formatter.format(-hours, 'hour')
}
return formatter.format(-Math.round(hours / 24), 'day')
return formatUiRelativeTime(-diffMs)
}
function MiniMaxCookieHelpPopover(): React.JSX.Element {
@@ -22,7 +22,8 @@ describe('workspace space format helpers', () => {
it('formats scan times as relative age labels', () => {
const now = new Date('2026-05-14T22:15:00Z').getTime()
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
// Pinned to 'en' so the expectation matches the UI language, not the machine's OS locale.
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
expect(getWorkspaceSpaceScanTimeLabel(now - 2 * 60_000, now)).toBe(
formatter.format(-2, 'minute')
@@ -3,9 +3,9 @@ import type {
WorkspaceSpaceScanStatus,
WorkspaceSpaceWorktree
} from '../../../../shared/workspace-space-types'
import { formatUiRelativeTime } from '@/i18n/relative-time-format'
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const
const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
const fullDateTimeFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
@@ -41,19 +41,7 @@ export function formatCompactCount(count: number): string {
}
export function getWorkspaceSpaceScanTimeLabel(scannedAt: number, now = Date.now()): string {
const diffMs = scannedAt - now
const diffMinutes = Math.round(diffMs / 60_000)
if (Math.abs(diffMinutes) < 60) {
return relativeTimeFormatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return relativeTimeFormatter.format(diffHours, 'hour')
}
const diffDays = Math.round(diffHours / 24)
return relativeTimeFormatter.format(diffDays, 'day')
return formatUiRelativeTime(scannedAt - now)
}
export function getWorkspaceSpaceScanDateTimeLabel(scannedAt: number): string {
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockIntlLocale } = vi.hoisted(() => ({ mockIntlLocale: { value: 'en' } }))
vi.mock('./i18n', () => ({ getIntlLocale: () => mockIntlLocale.value }))
import {
formatUiRelativeTime,
formatUiRelativeTimeFromDate,
getUiRelativeTimeFormatter
} from './relative-time-format'
beforeEach(() => {
mockIntlLocale.value = 'en'
})
describe('getUiRelativeTimeFormatter', () => {
it('formats with the configured UI language instead of the OS locale', () => {
expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('yesterday')
})
it('reuses the cached formatter while the language is unchanged', () => {
expect(getUiRelativeTimeFormatter()).toBe(getUiRelativeTimeFormatter())
})
it('rebuilds the formatter after a runtime language switch', () => {
const english = getUiRelativeTimeFormatter()
mockIntlLocale.value = 'ko'
const korean = getUiRelativeTimeFormatter()
expect(korean).not.toBe(english)
expect(korean.format(-1, 'day')).toBe('어제')
})
// getIntlLocale() already maps the synthetic plugin<hex> resource language to a real BCP-47 tag,
// so the constructor never sees a value Intl would reject.
it('formats in the tag a plugin language pack resolves to', () => {
mockIntlLocale.value = 'ja'
expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('昨日')
})
})
describe('formatUiRelativeTime', () => {
it('picks the minute/hour/day unit from the signed millisecond delta', () => {
expect(formatUiRelativeTime(-5 * 60_000)).toBe('5 minutes ago')
expect(formatUiRelativeTime(-3 * 3_600_000)).toBe('3 hours ago')
expect(formatUiRelativeTime(-2 * 86_400_000)).toBe('2 days ago')
})
it('formats future deltas', () => {
expect(formatUiRelativeTime(2 * 86_400_000)).toBe('in 2 days')
})
})
describe('formatUiRelativeTimeFromDate', () => {
it('returns the fallback for an invalid date', () => {
expect(formatUiRelativeTimeFromDate('not-a-date')).toBe('recently')
expect(formatUiRelativeTimeFromDate('not-a-date', 'unknown')).toBe('unknown')
})
})
@@ -0,0 +1,39 @@
import { getIntlLocale } from './i18n'
// Why: relative times are language words ("2 days ago" / "그저께"), so they must follow the
// configured UI language rather than the OS locale — unlike Intl.DateTimeFormat, where numeric
// date shapes following the OS region is conventional. The language can change at runtime, and
// plugin packs register under a synthetic tag Intl rejects, so resolve through getIntlLocale()
// and cache per resolved locale rather than freezing one formatter at module scope.
let cached: { locale: string; formatter: Intl.RelativeTimeFormat } | null = null
export function getUiRelativeTimeFormatter(): Intl.RelativeTimeFormat {
const locale = getIntlLocale()
if (!cached || cached.locale !== locale) {
cached = { locale, formatter: new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) }
}
return cached.formatter
}
/** Format a signed millisecond delta (future positive, past negative) at minute/hour/day granularity. */
export function formatUiRelativeTime(diffMs: number): string {
const formatter = getUiRelativeTimeFormatter()
const diffMinutes = Math.round(diffMs / 60_000)
if (Math.abs(diffMinutes) < 60) {
return formatter.format(diffMinutes, 'minute')
}
const diffHours = Math.round(diffMinutes / 60)
if (Math.abs(diffHours) < 24) {
return formatter.format(diffHours, 'hour')
}
return formatter.format(Math.round(diffHours / 24), 'day')
}
/** Parse a date string and format it relative to now; returns `fallback` when the input is invalid. */
export function formatUiRelativeTimeFromDate(input: string, fallback = 'recently'): string {
const date = new Date(input)
if (Number.isNaN(date.getTime())) {
return fallback
}
return formatUiRelativeTime(date.getTime() - Date.now())
}