diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index e7e9a67e16..326bceaacf 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_TIMESTAMP_FORMAT, getAppModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, @@ -57,3 +58,9 @@ describe("resolveAppModelSelection", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); }); + +describe("timestamp format defaults", () => { + it("defaults timestamp format to locale", () => { + expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale"); + }); +}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e5018e0bfd..ac30989cb1 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -7,6 +7,9 @@ import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; +export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const; +export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number]; +export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), }; @@ -22,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe( + Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)), + ), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a9c0371a1..b1f13ba3a6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -184,6 +184,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -3244,6 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) { onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} + timestampFormat={timestampFormat} workspaceRoot={activeProject?.cwd ?? undefined} /> @@ -3764,6 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProposedPlan={activeProposedPlan} markdownCwd={gitCwd ?? undefined} workspaceRoot={activeProject?.cwd ?? undefined} + timestampFormat={timestampFormat} onClose={() => { setPlanSidebarOpen(false); // Track that the user explicitly dismissed for this turn so auto-open won't fight them. diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index b0f1b91c35..6a3c008c51 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -25,6 +25,8 @@ import { buildPatchCacheKey } from "../lib/diffRendering"; import { resolveDiffThemeName } from "../lib/diffRendering"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; +import { useAppSettings } from "../appSettings"; +import { formatShortTimestamp } from "../timestampFormat"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; @@ -149,13 +151,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; } -function formatTurnChipTimestamp(isoDate: string): string { - return new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - }).format(new Date(isoDate)); -} - interface DiffPanelProps { mode?: "inline" | "sheet" | "sidebar"; } @@ -165,6 +160,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const navigate = useNavigate(); const { resolvedTheme } = useTheme(); + const { settings } = useAppSettings(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); @@ -487,7 +483,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { "?"} - {formatTurnChipTimestamp(summary.completedAt)} + {formatShortTimestamp(summary.completedAt, settings.timestampFormat)} diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx index 735900eacb..a1e18e4afd 100644 --- a/apps/web/src/components/PlanSidebar.tsx +++ b/apps/web/src/components/PlanSidebar.tsx @@ -1,4 +1,5 @@ import { memo, useState, useCallback, useRef, useEffect } from "react"; +import { type TimestampFormat } from "../appSettings"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { ScrollArea } from "./ui/scroll-area"; @@ -12,9 +13,9 @@ import { PanelRightCloseIcon, } from "lucide-react"; import { cn } from "~/lib/utils"; -import { formatTimestamp } from "../session-logic"; import type { ActivePlanState } from "../session-logic"; import type { LatestProposedPlanState } from "../session-logic"; +import { formatTimestamp } from "../timestampFormat"; import { proposedPlanTitle, buildProposedPlanMarkdownFilename, @@ -53,6 +54,7 @@ interface PlanSidebarProps { activeProposedPlan: LatestProposedPlanState | null; markdownCwd: string | undefined; workspaceRoot: string | undefined; + timestampFormat: TimestampFormat; onClose: () => void; } @@ -61,6 +63,7 @@ const PlanSidebar = memo(function PlanSidebar({ activeProposedPlan, markdownCwd, workspaceRoot, + timestampFormat, onClose, }: PlanSidebarProps) { const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false); @@ -145,7 +148,7 @@ const PlanSidebar = memo(function PlanSidebar({ {activePlan ? ( - {formatTimestamp(activePlan.createdAt)} + {formatTimestamp(activePlan.createdAt, timestampFormat)} ) : null} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7a89e762e3..0e6d470a02 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -5,7 +5,7 @@ import { type VirtualItem, useVirtualizer, } from "@tanstack/react-virtual"; -import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic"; +import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; @@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeMessageDurationStart } from "./MessagesTimeline.logic"; +import { type TimestampFormat } from "../../appSettings"; +import { formatTimestamp } from "../../timestampFormat"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -44,6 +46,7 @@ interface MessagesTimelineProps { onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; + timestampFormat: TimestampFormat; workspaceRoot: string | undefined; } @@ -67,6 +70,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onImageExpand, markdownCwd, resolvedTheme, + timestampFormat, workspaceRoot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); @@ -424,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ )}

- {formatTimestamp(row.message.createdAt)} + {formatTimestamp(row.message.createdAt, timestampFormat)}

@@ -515,6 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.streaming ? formatElapsed(row.durationStart, nowIso) : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, )}

@@ -650,9 +655,13 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } -function formatMessageMeta(createdAt: string, duration: string | null): string { - if (!duration) return formatTimestamp(createdAt); - return `${formatTimestamp(createdAt)} • ${duration}`; +function formatMessageMeta( + createdAt: string, + duration: string | null, + timestampFormat: TimestampFormat, +): string { + if (!duration) return formatTimestamp(createdAt, timestampFormat); + return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`; } function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index c0e9a7ef9f..1007b3130f 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -11,6 +11,13 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "../components/ui/select"; import { Switch } from "../components/ui/switch"; import { APP_VERSION } from "../branding"; import { SidebarInset } from "~/components/ui/sidebar"; @@ -49,6 +56,12 @@ const MODEL_PROVIDER_SETTINGS: Array<{ }, ] as const; +const TIMESTAMP_FORMAT_LABELS = { + locale: "System default", + "12-hour": "12-hour", + "24-hour": "24-hour", +} as const; + function getCustomModelsForProvider( settings: ReturnType["settings"], provider: ProviderKind, @@ -210,43 +223,89 @@ function SettingsRouteView() {

Appearance

- Choose how T3 Code handles light and dark mode. + Choose how T3 Code looks across the app.

-
- {THEME_OPTIONS.map((option) => { - const selected = theme === option.value; - return ( - - ); - })} -
+ {selected ? ( + + Selected + + ) : null} + + ); + })} + -

- Active theme: {resolvedTheme} -

+

+ Active theme: {resolvedTheme} +

+ +
+
+

Timestamp format

+

+ System default follows your browser or OS time format. 12-hour{" "} + and 24-hour force the hour cycle. +

+
+ +
+ + {settings.timestampFormat !== defaults.timestampFormat ? ( +
+ +
+ ) : null} +
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index aa8f3ffc35..4e395b8b26 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -89,14 +89,6 @@ export type TimelineEntry = entry: WorkLogEntry; }; -export function formatTimestamp(isoDate: string): string { - return new Intl.DateTimeFormat(undefined, { - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }).format(new Date(isoDate)); -} - export function formatDuration(durationMs: number): string { if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms"; if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`; diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts new file mode 100644 index 0000000000..f45ada7341 --- /dev/null +++ b/apps/web/src/timestampFormat.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { getTimestampFormatOptions } from "./timestampFormat"; + +describe("getTimestampFormatOptions", () => { + it("omits hour12 when locale formatting is requested", () => { + expect(getTimestampFormatOptions("locale", true)).toEqual({ + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }); + }); + + it("builds a 12-hour formatter with seconds when requested", () => { + expect(getTimestampFormatOptions("12-hour", true)).toEqual({ + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + }); + }); + + it("builds a 24-hour formatter without seconds when requested", () => { + expect(getTimestampFormatOptions("24-hour", false)).toEqual({ + hour: "numeric", + minute: "2-digit", + hour12: false, + }); + }); +}); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts new file mode 100644 index 0000000000..d4ffa3c376 --- /dev/null +++ b/apps/web/src/timestampFormat.ts @@ -0,0 +1,49 @@ +import { type TimestampFormat } from "./appSettings"; + +export function getTimestampFormatOptions( + timestampFormat: TimestampFormat, + includeSeconds: boolean, +): Intl.DateTimeFormatOptions { + const baseOptions: Intl.DateTimeFormatOptions = { + hour: "numeric", + minute: "2-digit", + ...(includeSeconds ? { second: "2-digit" } : {}), + }; + + if (timestampFormat === "locale") { + return baseOptions; + } + + return { + ...baseOptions, + hour12: timestampFormat === "12-hour", + }; +} + +const timestampFormatterCache = new Map(); + +function getTimestampFormatter( + timestampFormat: TimestampFormat, + includeSeconds: boolean, +): Intl.DateTimeFormat { + const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`; + const cachedFormatter = timestampFormatterCache.get(cacheKey); + if (cachedFormatter) { + return cachedFormatter; + } + + const formatter = new Intl.DateTimeFormat( + undefined, + getTimestampFormatOptions(timestampFormat, includeSeconds), + ); + timestampFormatterCache.set(cacheKey, formatter); + return formatter; +} + +export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { + return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate)); +} + +export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { + return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); +}