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 (
-
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));
+}