diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index e7e9a67e164f7580774c4d474fcc800a0cb0e90a..326bceaacfa2e8f14b6daebdd349a8e2278a6ea2 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 e5018e0bfdac2aab3cd4d96a2d3f417e21ee54f3..ac30989cb18143a7c742861378d38c91ec5b867e 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 0a9c0371a1b1c0e66208f232c7c9dfde1bdf57af..b1f13ba3a67815156482553088775eb1f98bba65 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 b0f1b91c357536df480bc370e33b63e64a29fb72..6a3c008c5119c4d818663ec0c3c8282f6e71e427 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 735900eacbdc3c51ffb2c93c5a65c8ca1a1e043b..a1e18e4afde382363873610ebffd8146f28591d4 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 7a89e762e3ef8a8ba7559e774b678f09fcc1f1ff..0e6d470a023cabc3a78ef3eeeb37ace813ed0784 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 c0e9a7ef9fbcb7507e46acbd09228bf839950114..1007b3130f6268203a74b7f7d6756fed11170c5b 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 (
-