Skip to content
Merged
5 changes: 3 additions & 2 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -937,13 +937,14 @@ const ThreadWorkLogRow = function ThreadWorkLogRow(
const warning = failureItem.failure.class === "usage_limit";
const timestamp = new Date(row.createdAt);
const resetAt = failureItem.failure.resetAt;
// The reset time is the viewer's local time; naming the zone keeps it unambiguous.
const resetTime = resetAt
? new Date(resetAt).toLocaleString(undefined, {
? `${new Date(resetAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
})
})} (${new Intl.DateTimeFormat().resolvedOptions().timeZone})`
: null;
const label = warning
? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
Expand Down
72 changes: 71 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { flushSync } from "react-dom";
import { AudioPreview } from "../files/AudioPreview";
import { page } from "vite-plus/test/browser";
import { cdp, page } from "vite-plus/test/browser";
import {
ComposerContextId,
EnvironmentId,
Expand Down Expand Up @@ -565,6 +565,76 @@ describe("MessagesTimeline messages", () => {
});
});

function usageLimitEntries(resetAt: string) {
const threadId = ThreadId.make("usage-limit-thread");
const at = DateTime.makeUnsafe(CREATED_AT);
return deriveTimelineEntriesFromVisibleTurnItems({
visibleTurnItems: [
{
position: 0,
visibility: "local",
sourceThreadId: threadId,
sourceItemId: TurnItemId.make("usage-limit"),
item: {
id: TurnItemId.make("usage-limit"),
threadId,
runId: RunId.make("usage-limit-run"),
nodeId: null,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 0,
title: null,
startedAt: at,
completedAt: at,
updatedAt: at,
type: "error",
status: "failed",
failure: {
class: "usage_limit",
message: "You've hit your usage limit.",
code: null,
retryable: true,
resetAt,
},
},
},
],
optimisticMessages: [],
});
}

describe("MessagesTimeline usage limit notice", () => {
it("labels the retry time with the viewer's timezone", async () => {
await using screen = await renderTimeline(usageLimitEntries("2026-03-17T19:30:00.000Z"));
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
expect(screen.text()).toMatch(/Usage limit reached\. Retry after .+\./);
expect(screen.text()).toContain(`(${timeZone})`);
});

it("keeps the retry time in the named zone after the viewer's timezone changes", async () => {
const resetAt = "2026-07-01T19:30:00.000Z";
const session = cdp();
await session.send("Emulation.setTimezoneOverride", { timezoneId: "Europe/Madrid" });
try {
{
await using _madrid = await renderTimeline(usageLimitEntries(resetAt));
}
await session.send("Emulation.setTimezoneOverride", { timezoneId: "America/New_York" });
await using screen = await renderTimeline(usageLimitEntries(resetAt));
const newYorkTime = new Date(resetAt).toLocaleTimeString(undefined, {
hour: "numeric",
minute: "2-digit",
timeZone: "America/New_York",
});
expect(screen.text()).toContain(`${newYorkTime} (America/New_York)`);
} finally {
await session.send("Emulation.setTimezoneOverride", { timezoneId: "" });
}
});
});

const CITATION_REQUEST = {
key: "activation-1",
citation: {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/chat/TimelineWorkEntryRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,12 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
if (failureItem?.type === "error" && failureItem.status === "failed") {
const warning = failureItem.failure.class === "usage_limit";
const resetAt = failureItem.failure.resetAt;
const resetTime = resetAt ? formatUpcomingTimestamp(resetAt, timestampFormat) : null;
// The reset time is the viewer's local time; naming the zone keeps it
// unambiguous. Both read the zone now, since the device's can change.
const timeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
const resetTime = resetAt
? `${formatUpcomingTimestamp(resetAt, timestampFormat, undefined, timeZone)} (${timeZone})`
: null;
const label = warning
? `Usage limit reached.${resetTime ? ` Retry after ${resetTime}.` : ""}`
: workEntry.label;
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/timestampFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vite-plus/test";

import { formatDayAwareTimestamp, formatUpcomingTimestamp } from "./timestampFormat";

describe("timestamps in an explicit time zone", () => {
// 23:00 UTC on July 1 is 7 PM on July 1 in New York, whatever the host zone.
const now = Date.parse("2026-07-01T23:00:00.000Z");
const zone = "America/New_York";

it("picks relative days from the calendar in that zone", () => {
expect(formatUpcomingTimestamp("2026-07-02T00:30:00.000Z", "12-hour", now, zone)).not.toMatch(
/tomorrow/,
);
expect(formatUpcomingTimestamp("2026-07-02T05:30:00.000Z", "12-hour", now, zone)).toMatch(
/^tomorrow at /,
);
expect(formatDayAwareTimestamp("2026-07-01T03:30:00.000Z", "12-hour", now, zone)).toMatch(
/^yesterday at /,
);
});
});
93 changes: 62 additions & 31 deletions apps/web/src/timestampFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,18 @@ const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
function getTimestampFormatter(
timestampFormat: TimestampFormat,
includeSeconds: boolean,
timeZone?: string,
): Intl.DateTimeFormat {
const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`;
const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}:${timeZone ?? ""}`;
const cachedFormatter = timestampFormatterCache.get(cacheKey);
if (cachedFormatter) {
return cachedFormatter;
}

const formatter = new Intl.DateTimeFormat(
timestampLocale,
getTimestampFormatOptions(timestampFormat, includeSeconds),
);
const formatter = new Intl.DateTimeFormat(timestampLocale, {
...getTimestampFormatOptions(timestampFormat, includeSeconds),
...(timeZone ? { timeZone } : {}),
});
timestampFormatterCache.set(cacheKey, formatter);
return formatter;
}
Expand Down Expand Up @@ -136,58 +137,88 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp
return getTimestampFormatter(timestampFormat, false).format(date);
}

const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, {
month: "numeric",
day: "numeric",
});
const numericDateWithYearFormatter = new Intl.DateTimeFormat(timestampLocale, {
month: "numeric",
day: "numeric",
const numericDateOptions: Intl.DateTimeFormatOptions = { month: "numeric", day: "numeric" };
const numericDateWithYearOptions: Intl.DateTimeFormatOptions = {
...numericDateOptions,
year: "numeric",
});
};
const numericDateFormatter = new Intl.DateTimeFormat(timestampLocale, numericDateOptions);
const numericDateWithYearFormatter = new Intl.DateTimeFormat(
timestampLocale,
numericDateWithYearOptions,
);

interface CalendarDay {
year: number;
/** Days since the epoch, so differences are whole days across DST shifts. */
index: number;
}

function calendarDay(date: Date, timeZone: string | undefined): CalendarDay {
if (!timeZone) {
const year = date.getFullYear();
return { year, index: Date.UTC(year, date.getMonth(), date.getDate()) / 86_400_000 };
}
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "numeric",
day: "numeric",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) =>
Number(parts.find((entry) => entry.type === type)?.value);
const year = part("year");
return { year, index: Date.UTC(year, part("month") - 1, part("day")) / 86_400_000 };
}

function formatNumericDate(date: Date, sameYear: boolean, timeZone: string | undefined): string {
if (!timeZone) {
return (sameYear ? numericDateFormatter : numericDateWithYearFormatter).format(date);
}
return new Intl.DateTimeFormat(timestampLocale, {
...(sameYear ? numericDateOptions : numericDateWithYearOptions),
timeZone,
}).format(date);
}

export function formatDayAwareTimestamp(
isoDate: string,
timestampFormat: TimestampFormat,
nowMs: number = Date.now(),
timeZone?: string,
): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const time = getTimestampFormatter(timestampFormat, false).format(date);
const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date);

const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfMessageDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
// Round so DST-shifted 23/25 hour days still count as whole days.
const dayDiff = Math.round((startOfToday - startOfMessageDay) / 86_400_000);
const today = calendarDay(new Date(nowMs), timeZone);
const messageDay = calendarDay(date, timeZone);
const dayDiff = today.index - messageDay.index;

if (dayDiff <= 0) return time;
if (dayDiff === 1) return `yesterday at ${time}`;
const dateFormatter =
date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
return `${dateFormatter.format(date)} ${time}`;
return `${formatNumericDate(date, messageDay.year === today.year, timeZone)} ${time}`;
}

/** `timeZone` overrides the zone the formatters cached when they were built. */
export function formatUpcomingTimestamp(
isoDate: string,
timestampFormat: TimestampFormat,
nowMs: number = Date.now(),
timeZone?: string,
): string {
const date = parseTimestampDate(isoDate);
if (!date) return "";
const time = getTimestampFormatter(timestampFormat, false).format(date);
const time = getTimestampFormatter(timestampFormat, false, timeZone).format(date);

const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfTargetDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
const dayDiff = Math.round((startOfTargetDay - startOfToday) / 86_400_000);
const today = calendarDay(new Date(nowMs), timeZone);
const targetDay = calendarDay(date, timeZone);
const dayDiff = targetDay.index - today.index;

if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs);
if (dayDiff < 0) return formatDayAwareTimestamp(isoDate, timestampFormat, nowMs, timeZone);
if (dayDiff === 0) return time;
if (dayDiff === 1) return `tomorrow at ${time}`;
const dateFormatter =
date.getFullYear() === now.getFullYear() ? numericDateFormatter : numericDateWithYearFormatter;
return `${dateFormatter.format(date)} ${time}`;
return `${formatNumericDate(date, targetDay.year === today.year, timeZone)} ${time}`;
}

type RelativeTimeParts = { value: string; suffix: string | null };
Expand Down
Loading