diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index de945054c893..da767a0370ca 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -40,6 +40,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 45e1c82460c8..eb045be282b6 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -21,6 +21,7 @@ describe("DesktopLifecycle", () => { const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index e0d229497aee..4189ea793e2d 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -8,6 +8,7 @@ const { autoUpdaterRemoveListenerMock, exitMock, getAppPathMock, + getSystemLocaleMock, getVersionMock, isDefaultProtocolClientMock, onMock, @@ -29,6 +30,7 @@ const { autoUpdaterRemoveListenerMock: vi.fn(), exitMock: vi.fn(), getAppPathMock: vi.fn(() => "/app"), + getSystemLocaleMock: vi.fn(() => "en-GB"), getVersionMock: vi.fn(() => "1.2.3"), isDefaultProtocolClientMock: vi.fn(() => false), onMock: vi.fn(), @@ -60,6 +62,7 @@ vi.mock("electron", () => ({ setIcon: setDockIconMock, }, getAppPath: getAppPathMock, + getSystemLocale: getSystemLocaleMock, getVersion: getVersionMock, isDefaultProtocolClient: isDefaultProtocolClientMock, isPackaged: true, @@ -111,6 +114,23 @@ describe("ElectronApp", () => { }).pipe(Effect.provide(ElectronApp.layer)), ); + it.effect("reads the OS locale through the service", () => + Effect.gen(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + + assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + + it.effect("normalizes POSIX-style locale identifiers that Intl rejects", () => + Effect.gen(function* () { + getSystemLocaleMock.mockImplementationOnce(() => "en_GB"); + const electronApp = yield* ElectronApp.ElectronApp; + + assert.strictEqual(yield* electronApp.systemLocale, "en-GB"); + }).pipe(Effect.provide(ElectronApp.layer)), + ); + it.effect("reports which app metadata property failed", () => Effect.gen(function* () { const cause = new Error("version unavailable"); diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 6fb84c53b367..5a6f16ae89fd 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service< { readonly metadata: Effect.Effect; readonly name: Effect.Effect; + /** + * The OS locale, read from the operating system rather than from Chromium's + * resolved application locale — the packaged app ships only the `en-US` + * locale pak, so `app.getLocale()` and the renderer's `Intl` default are + * pinned to `en-US` however the machine is configured. + */ + readonly systemLocale: Effect.Effect; readonly whenReady: Effect.Effect; readonly quit: Effect.Effect; readonly exit: (code: number) => Effect.Effect; @@ -119,6 +126,10 @@ export const make = ElectronApp.of({ }; }), name: Effect.sync(() => Electron.app.name), + // macOS derives this from NSLocale, which uses POSIX-style identifiers + // (`en_GB`). `Intl` rejects those outright rather than normalizing them, so + // the tag is normalized here rather than in the renderer that consumes it. + systemLocale: Effect.sync(() => Electron.app.getSystemLocale().replace(/_/g, "-")), whenReady: Effect.gen(function* () { const isPackaged = Electron.app.isPackaged; yield* Effect.tryPromise({ diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 3d9ff022c92d..37fd873a1b03 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -34,6 +34,7 @@ import { getAppBranding, getLocalEnvironmentBootstraps, getLocalEnvironmentBearerToken, + getSystemLocale, getWindowFullscreenState, openExternal, probeRemoteEditors, @@ -50,6 +51,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getSystemLocale); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index ac1ee8792806..02f9ad0df36e 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -15,6 +15,7 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_SYSTEM_LOCALE_CHANNEL = "desktop:get-system-locale"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 16f7a4694afa..0c7e90b95072 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -26,6 +26,7 @@ import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; +import * as ElectronApp from "../../electron/ElectronApp.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -64,6 +65,15 @@ export const getAppBranding = DesktopIpc.makeSyncIpcMethod({ }), }); +export const getSystemLocale = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_SYSTEM_LOCALE_CHANNEL, + result: Schema.String, + handler: Effect.fn("desktop.ipc.window.getSystemLocale")(function* () { + const electronApp = yield* ElectronApp.ElectronApp; + return yield* electronApp.systemLocale; + }), +}); + export const getWindowFullscreenState = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_WINDOW_FULLSCREEN_STATE_CHANNEL, result: Schema.Boolean, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index cbbadb708ab7..6741e3922391 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getSystemLocale: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); + return typeof result === "string" ? result : null; + }, getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 112c0ab350ee..3d912a5d5aa8 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -26,6 +26,7 @@ function makeElectronAppLayer( return Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 09c28776342c..595b0dd113d3 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -31,6 +31,7 @@ const environmentInput = { const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { metadata: Effect.die("unexpected metadata read"), name: Effect.succeed("T3 Code"), + systemLocale: Effect.succeed("en-US"), whenReady: Effect.void, quit: Effect.void, exit: () => Effect.void, diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 6678549ccd90..1ac2b04a4bb9 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -12,6 +12,7 @@ import { formatTimestamp, getRelativeTimeState, getTimestampFormatOptions, + resolveTimestampLocale, } from "./timestampFormat"; describe("getTimestampFormatOptions", () => { @@ -41,6 +42,40 @@ describe("getTimestampFormatOptions", () => { }); }); +describe("resolveTimestampLocale", () => { + it("defers to the runtime default when the host reports no locale", () => { + expect(resolveTimestampLocale(null)).toBeUndefined(); + expect(resolveTimestampLocale(undefined)).toBeUndefined(); + expect(resolveTimestampLocale(" ")).toBeUndefined(); + }); + + it("uses a BCP-47 tag reported by the host", () => { + expect(resolveTimestampLocale("en-GB")).toBe("en-GB"); + }); + + it("defers to the runtime default rather than throwing on an unusable tag", () => { + // The desktop bridge normalizes POSIX identifiers before reporting them, so + // anything Intl still rejects here falls back instead of breaking every + // timestamp in the UI. + expect(resolveTimestampLocale("not a locale")).toBeUndefined(); + expect(resolveTimestampLocale("en_GB")).toBeUndefined(); + }); + + it("renders the host locale's hour cycle under the locale setting", () => { + const formatAt1544 = (systemLocale: string | null) => + new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { + ...getTimestampFormatOptions("locale", false), + timeZone: "UTC", + }) + .format(new Date("2026-04-07T15:44:00.000Z")) + // ICU separates the day period with a narrow no-break space. + .replace(/[  ]/g, " "); + + expect(formatAt1544("en-GB")).toBe("15:44"); + expect(formatAt1544("en-US")).toBe("3:44 PM"); + }); +}); + describe("formatRelativeTimeUntilLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c8f9956ebb3f..5b57c5316e1d 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -20,6 +20,39 @@ export function getTimestampFormatOptions( }; } +/** + * Pick the locale to format wall-clock times in, given the locale the host + * reports. Hosts that report nothing fall back to `undefined`, which is the + * runtime default and the right answer in a browser. + * + * A host reports a locale only when it knows better than the runtime does — + * see `getSystemLocale` on the desktop bridge for why desktop does. + */ +export function resolveTimestampLocale( + systemLocale: string | null | undefined, +): string | undefined { + const tag = systemLocale?.trim(); + if (!tag) return undefined; + + try { + // Every timestamp in the UI runs through this formatter, so a tag the host + // could not normalize falls back rather than throwing. Throws on a + // structurally invalid tag; a well-formed tag ICU has no data for resolves + // here and is left to ICU's own fallback. + Intl.DateTimeFormat.supportedLocalesOf([tag]); + return tag; + } catch { + return undefined; + } +} + +function readHostSystemLocale(): string | null { + if (typeof window === "undefined") return null; + return window.desktopBridge?.getSystemLocale?.() ?? null; +} + +const timestampLocale = resolveTimestampLocale(readHostSystemLocale()); + const timestampFormatterCache = new Map(); function getTimestampFormatter( @@ -33,7 +66,7 @@ function getTimestampFormatter( } const formatter = new Intl.DateTimeFormat( - undefined, + timestampLocale, getTimestampFormatOptions(timestampFormat, includeSeconds), ); timestampFormatterCache.set(cacheKey, formatter); @@ -51,6 +84,9 @@ export function formatTimestamp(isoDate: string, timestampFormat: TimestampForma return getTimestampFormatter(timestampFormat, true).format(date); } +// Deliberately not the host locale: the tooltip's ordinal suffix and +// day-before-month order below are English, so a localized month alone would +// read "4th Juni 2026". Localizing the whole label is a separate change. const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); function ordinalSuffix(day: number): string { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3341c0bb062f..d872a422a516 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1021,6 +1021,13 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + /** + * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: + * the packaged app ships only the `en-US` Chromium locale pak, so + * `navigator.language` and the default `Intl` locale are pinned to `en-US` + * regardless of OS settings. + */ + getSystemLocale?: () => string | null; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID.