Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/electron/ElectronApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
autoUpdaterRemoveListenerMock,
exitMock,
getAppPathMock,
getSystemLocaleMock,
getVersionMock,
isDefaultProtocolClientMock,
onMock,
Expand All @@ -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(),
Expand Down Expand Up @@ -60,6 +62,7 @@ vi.mock("electron", () => ({
setIcon: setDockIconMock,
},
getAppPath: getAppPathMock,
getSystemLocale: getSystemLocaleMock,
getVersion: getVersionMock,
isDefaultProtocolClient: isDefaultProtocolClientMock,
isPackaged: true,
Expand Down Expand Up @@ -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");
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/electron/ElectronApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export class ElectronApp extends Context.Service<
{
readonly metadata: Effect.Effect<ElectronAppMetadata, ElectronAppMetadataReadError>;
readonly name: Effect.Effect<string>;
/**
* 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<string>;
readonly whenReady: Effect.Effect<void, ElectronAppWhenReadyError>;
readonly quit: Effect.Effect<void>;
readonly exit: (code: number) => Effect.Effect<void>;
Expand Down Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
getAppBranding,
getLocalEnvironmentBootstraps,
getLocalEnvironmentBearerToken,
getSystemLocale,
getWindowFullscreenState,
openExternal,
probeRemoteEditors,
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ contextBridge.exposeInMainWorld("desktopBridge", {
}
return result as ReturnType<DesktopBridge["getAppBranding"]>;
},
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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
formatTimestamp,
getRelativeTimeState,
getTimestampFormatOptions,
resolveTimestampLocale,
} from "./timestampFormat";

describe("getTimestampFormatOptions", () => {
Expand Down Expand Up @@ -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();
Expand Down
38 changes: 37 additions & 1 deletion apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Intl.DateTimeFormat>();

function getTimestampFormatter(
Expand All @@ -33,7 +66,7 @@ function getTimestampFormatter(
}

const formatter = new Intl.DateTimeFormat(
undefined,
timestampLocale,
getTimestampFormatOptions(timestampFormat, includeSeconds),
);
timestampFormatterCache.set(cacheKey, formatter);
Expand All @@ -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.
Comment on lines +87 to +89

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The English-tooltip exception is well justified, but the two numeric date formatters below (numericDateFormatter, numericDateWithYearFormatter) are still built with undefined while the time formatter now uses timestampLocale. formatDayAwareTimestamp concatenates the two, so on packaged desktop — where the runtime default is pinned to en-US — an en-GB machine renders 8/13 15:44 instead of 13/08 15:44, i.e. UK hour cycle with US month/day order in the same label. That also contradicts the formatDayAwareTimestamp docstring, which promises "locale digit order".

These formatters emit no English words, so the reason given here for monthNameFormatter doesn't apply to them; suggest passing timestampLocale to both (they are declared after the timestampLocale binding, so no reordering is needed), or adding a short note if leaving them on the runtime default is deliberate.

Posted via Macroscope — UI Consistency

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chat dates stay US-ordered

Medium Severity

formatDayAwareTimestamp now formats the time with timestampLocale but still builds the date prefix via numericDateFormatter and numericDateWithYearFormatter on the runtime default. On packaged desktop that default stays en-US, so older chat timestamps pair an OS-locale time with US M/D order. Unlike monthNameFormatter, these numeric formatters have no English-only scaffolding that would make the host locale unsafe.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 244ca16. Configure here.

const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" });

function ordinalSuffix(day: number): string {
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading