From 17cf52ddb13df1eeb92e41ac6c9d226731977785 Mon Sep 17 00:00:00 2001
From: Hugo Blom
Date: Tue, 10 Mar 2026 22:23:56 +0100
Subject: [PATCH 1/7] Add 24-hour timestamp support
---
apps/web/src/appSettings.test.ts | 67 +++++++-
apps/web/src/appSettings.ts | 143 +++++++++++++++---
apps/web/src/components/ChatView.tsx | 3 +
apps/web/src/components/DiffPanel.tsx | 12 +-
apps/web/src/components/PlanSidebar.tsx | 6 +-
.../src/components/chat/MessagesTimeline.tsx | 18 ++-
apps/web/src/routes/_chat.settings.tsx | 43 ++++++
apps/web/src/session-logic.ts | 8 -
apps/web/src/timestampFormat.test.ts | 22 +++
apps/web/src/timestampFormat.ts | 36 +++++
10 files changed, 317 insertions(+), 41 deletions(-)
create mode 100644 apps/web/src/timestampFormat.test.ts
create mode 100644 apps/web/src/timestampFormat.ts
diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index e7e9a67e16..6ae35e7dd3 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -1,11 +1,76 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
+ getAppSettingsSnapshot,
getAppModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
} from "./appSettings";
+describe("getAppSettingsSnapshot", () => {
+ beforeEach(() => {
+ const storage = new Map();
+ vi.stubGlobal("window", {
+ localStorage: {
+ clear() {
+ storage.clear();
+ },
+ getItem(key: string) {
+ return storage.get(key) ?? null;
+ },
+ removeItem(key: string) {
+ storage.delete(key);
+ },
+ setItem(key: string, value: string) {
+ storage.set(key, value);
+ },
+ },
+ addEventListener() {},
+ removeEventListener() {},
+ });
+ window.localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("defaults 24-hour timestamps to disabled", () => {
+ expect(getAppSettingsSnapshot().use24HourTimestamps).toBe(false);
+ });
+
+ it("hydrates persisted 24-hour timestamp preferences", () => {
+ window.localStorage.setItem(
+ "t3code:app-settings:v1",
+ JSON.stringify({
+ use24HourTimestamps: true,
+ }),
+ );
+
+ expect(getAppSettingsSnapshot()).toMatchObject({
+ use24HourTimestamps: true,
+ enableAssistantStreaming: false,
+ confirmThreadDelete: true,
+ });
+ });
+
+ it("keeps existing settings when older persisted payloads omit newer keys", () => {
+ window.localStorage.setItem(
+ "t3code:app-settings:v1",
+ JSON.stringify({
+ codexBinaryPath: "/usr/local/bin/codex-nightly",
+ enableAssistantStreaming: true,
+ }),
+ );
+
+ expect(getAppSettingsSnapshot()).toMatchObject({
+ codexBinaryPath: "/usr/local/bin/codex-nightly",
+ enableAssistantStreaming: true,
+ use24HourTimestamps: false,
+ });
+ });
+});
+
describe("normalizeCustomModelSlugs", () => {
it("normalizes aliases, removes built-ins, and deduplicates values", () => {
expect(
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index e5018e0bfd..e53db8fab7 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -1,8 +1,7 @@
-import { useCallback } from "react";
+import { useCallback, useSyncExternalStore } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
-import { useLocalStorage } from "./hooks/useLocalStorage";
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
@@ -22,6 +21,9 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
+ use24HourTimestamps: Schema.Boolean.pipe(
+ Schema.withConstructorDefault(() => Option.some(false)),
+ ),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
@@ -35,6 +37,10 @@ export interface AppModelOption {
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
+let listeners: Array<() => void> = [];
+let cachedRawSettings: string | null | undefined;
+let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
+
export function normalizeCustomModelSlugs(
models: Iterable,
provider: ProviderKind = "codex",
@@ -64,6 +70,17 @@ export function normalizeCustomModelSlugs(
return normalizedModels;
}
+function normalizeAppSettings(settings: AppSettings): AppSettings {
+ return {
+ ...settings,
+ customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
+ };
+}
+
+function decodeAppSettings(value: unknown): AppSettings {
+ return normalizeAppSettings(Schema.decodeUnknownSync(AppSettingsSchema)(value));
+}
+
export function getAppModelOptions(
provider: ProviderKind,
customModels: readonly string[],
@@ -133,26 +150,118 @@ export function resolveAppModelSelection(
);
}
+export function getSlashModelOptions(
+ provider: ProviderKind,
+ customModels: readonly string[],
+ query: string,
+ selectedModel?: string | null,
+): AppModelOption[] {
+ const normalizedQuery = query.trim().toLowerCase();
+ const options = getAppModelOptions(provider, customModels, selectedModel);
+ if (!normalizedQuery) {
+ return options;
+ }
+
+ return options.filter((option) => {
+ const searchSlug = option.slug.toLowerCase();
+ const searchName = option.name.toLowerCase();
+ return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery);
+ });
+}
+
+function emitChange(): void {
+ for (const listener of listeners) {
+ listener();
+ }
+}
+
+function parsePersistedSettings(value: string | null): AppSettings {
+ if (!value) {
+ return DEFAULT_APP_SETTINGS;
+ }
+
+ try {
+ const parsed = JSON.parse(value);
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return DEFAULT_APP_SETTINGS;
+ }
+
+ return decodeAppSettings({
+ ...DEFAULT_APP_SETTINGS,
+ ...parsed,
+ });
+ } catch {
+ return DEFAULT_APP_SETTINGS;
+ }
+}
+
+export function getAppSettingsSnapshot(): AppSettings {
+ if (typeof window === "undefined") {
+ return DEFAULT_APP_SETTINGS;
+ }
+
+ const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
+ if (raw === cachedRawSettings) {
+ return cachedSnapshot;
+ }
+
+ cachedRawSettings = raw;
+ cachedSnapshot = parsePersistedSettings(raw);
+ return cachedSnapshot;
+}
+
+function persistSettings(next: AppSettings): void {
+ if (typeof window === "undefined") return;
+
+ const raw = JSON.stringify(next);
+ try {
+ if (raw !== cachedRawSettings) {
+ window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw);
+ }
+ } catch {
+ // Best-effort persistence only.
+ }
+
+ cachedRawSettings = raw;
+ cachedSnapshot = next;
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.push(listener);
+
+ const onStorage = (event: StorageEvent) => {
+ if (event.key === APP_SETTINGS_STORAGE_KEY) {
+ emitChange();
+ }
+ };
+
+ window.addEventListener("storage", onStorage);
+ return () => {
+ listeners = listeners.filter((entry) => entry !== listener);
+ window.removeEventListener("storage", onStorage);
+ };
+}
+
export function useAppSettings() {
- const [settings, setSettings] = useLocalStorage(
- APP_SETTINGS_STORAGE_KEY,
- DEFAULT_APP_SETTINGS,
- AppSettingsSchema,
+ const settings = useSyncExternalStore(
+ subscribe,
+ getAppSettingsSnapshot,
+ () => DEFAULT_APP_SETTINGS,
);
- const updateSettings = useCallback(
- (patch: Partial) => {
- setSettings((prev) => ({
- ...prev,
- ...patch,
- }));
- },
- [setSettings],
- );
+ const updateSettings = useCallback((patch: Partial) => {
+ const next = decodeAppSettings({
+ ...getAppSettingsSnapshot(),
+ ...patch,
+ });
+ persistSettings(next);
+ emitChange();
+ }, []);
const resetSettings = useCallback(() => {
- setSettings(DEFAULT_APP_SETTINGS);
- }, [setSettings]);
+ persistSettings(DEFAULT_APP_SETTINGS);
+ emitChange();
+ }, []);
return {
settings,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 0a9c0371a1..ad53e33a0b 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 use24HourTimestamps = settings.use24HourTimestamps;
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}
+ use24HourTimestamps={use24HourTimestamps}
workspaceRoot={activeProject?.cwd ?? undefined}
/>
@@ -3764,6 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeProposedPlan={activeProposedPlan}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeProject?.cwd ?? undefined}
+ use24HourTimestamps={use24HourTimestamps}
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..a09d501ff6 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.use24HourTimestamps)}
diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx
index 735900eacb..b40384e909 100644
--- a/apps/web/src/components/PlanSidebar.tsx
+++ b/apps/web/src/components/PlanSidebar.tsx
@@ -12,9 +12,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 +53,7 @@ interface PlanSidebarProps {
activeProposedPlan: LatestProposedPlanState | null;
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
+ use24HourTimestamps: boolean;
onClose: () => void;
}
@@ -61,6 +62,7 @@ const PlanSidebar = memo(function PlanSidebar({
activeProposedPlan,
markdownCwd,
workspaceRoot,
+ use24HourTimestamps,
onClose,
}: PlanSidebarProps) {
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
@@ -145,7 +147,7 @@ const PlanSidebar = memo(function PlanSidebar({
{activePlan ? (
- {formatTimestamp(activePlan.createdAt)}
+ {formatTimestamp(activePlan.createdAt, use24HourTimestamps)}
) : null}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index 7a89e762e3..e84407b42a 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,7 @@ import { ChangedFilesTree } from "./ChangedFilesTree";
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
import { MessageCopyButton } from "./MessageCopyButton";
import { computeMessageDurationStart } from "./MessagesTimeline.logic";
+import { formatTimestamp } from "../../timestampFormat";
const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
@@ -44,6 +45,7 @@ interface MessagesTimelineProps {
onImageExpand: (preview: ExpandedImagePreview) => void;
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
+ use24HourTimestamps: boolean;
workspaceRoot: string | undefined;
}
@@ -67,6 +69,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onImageExpand,
markdownCwd,
resolvedTheme,
+ use24HourTimestamps,
workspaceRoot,
}: MessagesTimelineProps) {
const timelineRootRef = useRef(null);
@@ -424,7 +427,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)}
- {formatTimestamp(row.message.createdAt)}
+ {formatTimestamp(row.message.createdAt, use24HourTimestamps)}
@@ -515,6 +518,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
row.message.streaming
? formatElapsed(row.durationStart, nowIso)
: formatElapsed(row.durationStart, row.message.completedAt),
+ use24HourTimestamps,
)}
@@ -650,9 +654,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,
+ use24HourTimestamps: boolean,
+): string {
+ if (!duration) return formatTimestamp(createdAt, use24HourTimestamps);
+ return `${formatTimestamp(createdAt, use24HourTimestamps)} • ${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..6ff3618c16 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -249,6 +249,49 @@ function SettingsRouteView() {
+
+
+
Time
+
+ Control how timestamps are shown across chat history and diffs.
+
+
+
+
+
+
Use 24-hour timestamps
+
+ Show times like 13:42:09 instead of 1:42:09 PM.
+
+
+
+ updateSettings({
+ use24HourTimestamps: Boolean(checked),
+ })
+ }
+ aria-label="Use 24-hour timestamps"
+ />
+
+
+ {settings.use24HourTimestamps !== defaults.use24HourTimestamps ? (
+
+
+ updateSettings({
+ use24HourTimestamps: defaults.use24HourTimestamps,
+ })
+ }
+ >
+ Restore default
+
+
+ ) : null}
+
+
Codex App Server
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..3946783a14
--- /dev/null
+++ b/apps/web/src/timestampFormat.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+
+import { getTimestampFormatOptions } from "./timestampFormat";
+
+describe("getTimestampFormatOptions", () => {
+ it("builds a 12-hour formatter with seconds when requested", () => {
+ expect(getTimestampFormatOptions(false, true)).toEqual({
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: true,
+ });
+ });
+
+ it("builds a 24-hour formatter without seconds when requested", () => {
+ expect(getTimestampFormatOptions(true, 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..4e2c5db7c9
--- /dev/null
+++ b/apps/web/src/timestampFormat.ts
@@ -0,0 +1,36 @@
+export function getTimestampFormatOptions(
+ use24Hour: boolean,
+ includeSeconds: boolean,
+): Intl.DateTimeFormatOptions {
+ return {
+ hour: "numeric",
+ minute: "2-digit",
+ ...(includeSeconds ? { second: "2-digit" } : {}),
+ hour12: !use24Hour,
+ };
+}
+
+const timestampFormatterCache = new Map
();
+
+function getTimestampFormatter(use24Hour: boolean, includeSeconds: boolean): Intl.DateTimeFormat {
+ const cacheKey = `${use24Hour ? "24" : "12"}:${includeSeconds ? "seconds" : "minutes"}`;
+ const cachedFormatter = timestampFormatterCache.get(cacheKey);
+ if (cachedFormatter) {
+ return cachedFormatter;
+ }
+
+ const formatter = new Intl.DateTimeFormat(
+ undefined,
+ getTimestampFormatOptions(use24Hour, includeSeconds),
+ );
+ timestampFormatterCache.set(cacheKey, formatter);
+ return formatter;
+}
+
+export function formatTimestamp(isoDate: string, use24Hour: boolean): string {
+ return getTimestampFormatter(use24Hour, true).format(new Date(isoDate));
+}
+
+export function formatShortTimestamp(isoDate: string, use24Hour: boolean): string {
+ return getTimestampFormatter(use24Hour, false).format(new Date(isoDate));
+}
From 735474629d5d3820796ba846f0a175d12ee9d408 Mon Sep 17 00:00:00 2001
From: huxcrux
Date: Thu, 12 Mar 2026 20:33:22 +0100
Subject: [PATCH 2/7] fix(web): remove unrelated app settings refactor
---
apps/web/src/appSettings.test.ts | 67 +-------------
apps/web/src/appSettings.ts | 144 ++++---------------------------
2 files changed, 19 insertions(+), 192 deletions(-)
diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index 6ae35e7dd3..e7e9a67e16 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -1,76 +1,11 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { describe, expect, it } from "vitest";
import {
- getAppSettingsSnapshot,
getAppModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
} from "./appSettings";
-describe("getAppSettingsSnapshot", () => {
- beforeEach(() => {
- const storage = new Map();
- vi.stubGlobal("window", {
- localStorage: {
- clear() {
- storage.clear();
- },
- getItem(key: string) {
- return storage.get(key) ?? null;
- },
- removeItem(key: string) {
- storage.delete(key);
- },
- setItem(key: string, value: string) {
- storage.set(key, value);
- },
- },
- addEventListener() {},
- removeEventListener() {},
- });
- window.localStorage.clear();
- });
-
- afterEach(() => {
- vi.unstubAllGlobals();
- });
-
- it("defaults 24-hour timestamps to disabled", () => {
- expect(getAppSettingsSnapshot().use24HourTimestamps).toBe(false);
- });
-
- it("hydrates persisted 24-hour timestamp preferences", () => {
- window.localStorage.setItem(
- "t3code:app-settings:v1",
- JSON.stringify({
- use24HourTimestamps: true,
- }),
- );
-
- expect(getAppSettingsSnapshot()).toMatchObject({
- use24HourTimestamps: true,
- enableAssistantStreaming: false,
- confirmThreadDelete: true,
- });
- });
-
- it("keeps existing settings when older persisted payloads omit newer keys", () => {
- window.localStorage.setItem(
- "t3code:app-settings:v1",
- JSON.stringify({
- codexBinaryPath: "/usr/local/bin/codex-nightly",
- enableAssistantStreaming: true,
- }),
- );
-
- expect(getAppSettingsSnapshot()).toMatchObject({
- codexBinaryPath: "/usr/local/bin/codex-nightly",
- enableAssistantStreaming: true,
- use24HourTimestamps: false,
- });
- });
-});
-
describe("normalizeCustomModelSlugs", () => {
it("normalizes aliases, removes built-ins, and deduplicates values", () => {
expect(
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index e53db8fab7..1438240739 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -1,7 +1,8 @@
-import { useCallback, useSyncExternalStore } from "react";
+import { useCallback } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
+import { useLocalStorage } from "./hooks/useLocalStorage";
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
const MAX_CUSTOM_MODEL_COUNT = 32;
@@ -21,9 +22,7 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
- use24HourTimestamps: Schema.Boolean.pipe(
- Schema.withConstructorDefault(() => Option.some(false)),
- ),
+ use24HourTimestamps: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(false))),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
@@ -37,10 +36,6 @@ export interface AppModelOption {
const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
-let listeners: Array<() => void> = [];
-let cachedRawSettings: string | null | undefined;
-let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
-
export function normalizeCustomModelSlugs(
models: Iterable,
provider: ProviderKind = "codex",
@@ -70,17 +65,6 @@ export function normalizeCustomModelSlugs(
return normalizedModels;
}
-function normalizeAppSettings(settings: AppSettings): AppSettings {
- return {
- ...settings,
- customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
- };
-}
-
-function decodeAppSettings(value: unknown): AppSettings {
- return normalizeAppSettings(Schema.decodeUnknownSync(AppSettingsSchema)(value));
-}
-
export function getAppModelOptions(
provider: ProviderKind,
customModels: readonly string[],
@@ -150,118 +134,26 @@ export function resolveAppModelSelection(
);
}
-export function getSlashModelOptions(
- provider: ProviderKind,
- customModels: readonly string[],
- query: string,
- selectedModel?: string | null,
-): AppModelOption[] {
- const normalizedQuery = query.trim().toLowerCase();
- const options = getAppModelOptions(provider, customModels, selectedModel);
- if (!normalizedQuery) {
- return options;
- }
-
- return options.filter((option) => {
- const searchSlug = option.slug.toLowerCase();
- const searchName = option.name.toLowerCase();
- return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery);
- });
-}
-
-function emitChange(): void {
- for (const listener of listeners) {
- listener();
- }
-}
-
-function parsePersistedSettings(value: string | null): AppSettings {
- if (!value) {
- return DEFAULT_APP_SETTINGS;
- }
-
- try {
- const parsed = JSON.parse(value);
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
- return DEFAULT_APP_SETTINGS;
- }
-
- return decodeAppSettings({
- ...DEFAULT_APP_SETTINGS,
- ...parsed,
- });
- } catch {
- return DEFAULT_APP_SETTINGS;
- }
-}
-
-export function getAppSettingsSnapshot(): AppSettings {
- if (typeof window === "undefined") {
- return DEFAULT_APP_SETTINGS;
- }
-
- const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
- if (raw === cachedRawSettings) {
- return cachedSnapshot;
- }
-
- cachedRawSettings = raw;
- cachedSnapshot = parsePersistedSettings(raw);
- return cachedSnapshot;
-}
-
-function persistSettings(next: AppSettings): void {
- if (typeof window === "undefined") return;
-
- const raw = JSON.stringify(next);
- try {
- if (raw !== cachedRawSettings) {
- window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw);
- }
- } catch {
- // Best-effort persistence only.
- }
-
- cachedRawSettings = raw;
- cachedSnapshot = next;
-}
-
-function subscribe(listener: () => void): () => void {
- listeners.push(listener);
-
- const onStorage = (event: StorageEvent) => {
- if (event.key === APP_SETTINGS_STORAGE_KEY) {
- emitChange();
- }
- };
-
- window.addEventListener("storage", onStorage);
- return () => {
- listeners = listeners.filter((entry) => entry !== listener);
- window.removeEventListener("storage", onStorage);
- };
-}
-
export function useAppSettings() {
- const settings = useSyncExternalStore(
- subscribe,
- getAppSettingsSnapshot,
- () => DEFAULT_APP_SETTINGS,
+ const [settings, setSettings] = useLocalStorage(
+ APP_SETTINGS_STORAGE_KEY,
+ DEFAULT_APP_SETTINGS,
+ AppSettingsSchema,
);
- const updateSettings = useCallback((patch: Partial) => {
- const next = decodeAppSettings({
- ...getAppSettingsSnapshot(),
- ...patch,
- });
- persistSettings(next);
- emitChange();
- }, []);
+ const updateSettings = useCallback(
+ (patch: Partial) => {
+ setSettings((prev) => ({
+ ...prev,
+ ...patch,
+ }));
+ },
+ [setSettings],
+ );
const resetSettings = useCallback(() => {
- persistSettings(DEFAULT_APP_SETTINGS);
- emitChange();
- }, []);
+ setSettings(DEFAULT_APP_SETTINGS);
+ }, [setSettings]);
return {
settings,
From 0eb7e8f21eb3954d23c4c196ba6fdfcff61694da Mon Sep 17 00:00:00 2001
From: huxcrux
Date: Thu, 12 Mar 2026 20:44:56 +0100
Subject: [PATCH 3/7] Move timestamp setting into appearance
---
apps/web/src/routes/_chat.settings.tsx | 131 ++++++++++++-------------
1 file changed, 62 insertions(+), 69 deletions(-)
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 6ff3618c16..7d88081f7a 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -210,86 +210,79 @@ 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 (
-
setTheme(option.value)}
- >
-
- {option.label}
- {option.description}
-
- {selected ? (
-
- Selected
+
+
+ {THEME_OPTIONS.map((option) => {
+ const selected = theme === option.value;
+ return (
+ setTheme(option.value)}
+ >
+
+ {option.label}
+ {option.description}
- ) : null}
-
- );
- })}
-
-
-
- Active theme: {resolvedTheme}
-
-
+ {selected ? (
+
+ Selected
+
+ ) : null}
+
+ );
+ })}
+
-
-
-
Time
-
- Control how timestamps are shown across chat history and diffs.
+
+ Active theme: {resolvedTheme}
-
-
-
-
-
Use 24-hour timestamps
-
- Show times like 13:42:09 instead of 1:42:09 PM.
-
-
-
- updateSettings({
- use24HourTimestamps: Boolean(checked),
- })
- }
- aria-label="Use 24-hour timestamps"
- />
-
- {settings.use24HourTimestamps !== defaults.use24HourTimestamps ? (
-
-
+
+
+
Use 24-hour timestamps
+
+ Show times like 13:42:09 instead of 1:42:09 PM.
+
+
+
updateSettings({
- use24HourTimestamps: defaults.use24HourTimestamps,
+ use24HourTimestamps: Boolean(checked),
})
}
- >
- Restore default
-
+ aria-label="Use 24-hour timestamps"
+ />
- ) : null}
+
+ {settings.use24HourTimestamps !== defaults.use24HourTimestamps ? (
+
+
+ updateSettings({
+ use24HourTimestamps: defaults.use24HourTimestamps,
+ })
+ }
+ >
+ Restore default
+
+
+ ) : null}
+
From 5633b7e01a45c958614c5b350ef2821120640edc Mon Sep 17 00:00:00 2001
From: huxcrux
Date: Thu, 12 Mar 2026 21:22:19 +0100
Subject: [PATCH 4/7] refactor(web): use string timestamp format setting
---
apps/web/src/appSettings.test.ts | 20 +++++++
apps/web/src/appSettings.ts | 60 ++++++++++++++++++-
apps/web/src/components/ChatView.tsx | 6 +-
apps/web/src/components/DiffPanel.tsx | 2 +-
apps/web/src/components/PlanSidebar.tsx | 7 ++-
.../src/components/chat/MessagesTimeline.tsx | 15 ++---
apps/web/src/routes/_chat.settings.tsx | 40 +++++++++----
apps/web/src/timestampFormat.test.ts | 4 +-
apps/web/src/timestampFormat.ts | 27 ++++++---
9 files changed, 142 insertions(+), 39 deletions(-)
diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index e7e9a67e16..0ffe6c0c00 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -4,6 +4,7 @@ import {
getAppModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
+ resolveLegacyTimestampFormat,
} from "./appSettings";
describe("normalizeCustomModelSlugs", () => {
@@ -57,3 +58,22 @@ describe("resolveAppModelSelection", () => {
expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
});
});
+
+describe("resolveLegacyTimestampFormat", () => {
+ it("migrates legacy enabled booleans to 24-hour format", () => {
+ expect(resolveLegacyTimestampFormat({ use24HourTimestamps: true })).toBe("24-hour");
+ });
+
+ it("migrates legacy disabled booleans to 12-hour format", () => {
+ expect(resolveLegacyTimestampFormat({ use24HourTimestamps: false })).toBe("12-hour");
+ });
+
+ it("ignores legacy booleans when the new format is already present", () => {
+ expect(
+ resolveLegacyTimestampFormat({
+ timestampFormat: "24-hour",
+ use24HourTimestamps: false,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts
index 1438240739..03140b70ee 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -1,4 +1,4 @@
-import { useCallback } from "react";
+import { useCallback, useEffect } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
@@ -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 = ["12-hour", "24-hour"] as const;
+export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
+export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "12-hour";
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};
@@ -22,7 +25,9 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
- use24HourTimestamps: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(false))),
+ timestampFormat: Schema.Literals(["12-hour", "24-hour"]).pipe(
+ Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)),
+ ),
customCodexModels: Schema.Array(Schema.String).pipe(
Schema.withConstructorDefault(() => Option.some([])),
),
@@ -134,6 +139,38 @@ export function resolveAppModelSelection(
);
}
+function parseStoredAppSettingsValue(raw: string | null): unknown {
+ if (!raw) {
+ return null;
+ }
+ try {
+ return JSON.parse(raw) as unknown;
+ } catch {
+ return null;
+ }
+}
+
+export function resolveLegacyTimestampFormat(value: unknown): TimestampFormat | null {
+ if (!value || typeof value !== "object") {
+ return null;
+ }
+
+ const candidate = value as Record;
+ if (candidate.timestampFormat === "12-hour" || candidate.timestampFormat === "24-hour") {
+ return null;
+ }
+
+ if (candidate.use24HourTimestamps === true) {
+ return "24-hour";
+ }
+
+ if (candidate.use24HourTimestamps === false) {
+ return "12-hour";
+ }
+
+ return null;
+}
+
export function useAppSettings() {
const [settings, setSettings] = useLocalStorage(
APP_SETTINGS_STORAGE_KEY,
@@ -155,6 +192,25 @@ export function useAppSettings() {
setSettings(DEFAULT_APP_SETTINGS);
}, [setSettings]);
+ useEffect(() => {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ const rawSettings = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
+ const legacyTimestampFormat = resolveLegacyTimestampFormat(
+ parseStoredAppSettingsValue(rawSettings),
+ );
+ if (!legacyTimestampFormat || settings.timestampFormat === legacyTimestampFormat) {
+ return;
+ }
+
+ setSettings((previous) => ({
+ ...previous,
+ timestampFormat: legacyTimestampFormat,
+ }));
+ }, [setSettings, settings.timestampFormat]);
+
return {
settings,
updateSettings,
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index ad53e33a0b..b1f13ba3a6 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -184,7 +184,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const { settings } = useAppSettings();
- const use24HourTimestamps = settings.use24HourTimestamps;
+ const timestampFormat = settings.timestampFormat;
const navigate = useNavigate();
const rawSearch = useSearch({
strict: false,
@@ -3245,7 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
onImageExpand={onExpandTimelineImage}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
- use24HourTimestamps={use24HourTimestamps}
+ timestampFormat={timestampFormat}
workspaceRoot={activeProject?.cwd ?? undefined}
/>
@@ -3766,7 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
activeProposedPlan={activeProposedPlan}
markdownCwd={gitCwd ?? undefined}
workspaceRoot={activeProject?.cwd ?? undefined}
- use24HourTimestamps={use24HourTimestamps}
+ 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 a09d501ff6..6a3c008c51 100644
--- a/apps/web/src/components/DiffPanel.tsx
+++ b/apps/web/src/components/DiffPanel.tsx
@@ -483,7 +483,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
"?"}
- {formatShortTimestamp(summary.completedAt, settings.use24HourTimestamps)}
+ {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
diff --git a/apps/web/src/components/PlanSidebar.tsx b/apps/web/src/components/PlanSidebar.tsx
index b40384e909..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";
@@ -53,7 +54,7 @@ interface PlanSidebarProps {
activeProposedPlan: LatestProposedPlanState | null;
markdownCwd: string | undefined;
workspaceRoot: string | undefined;
- use24HourTimestamps: boolean;
+ timestampFormat: TimestampFormat;
onClose: () => void;
}
@@ -62,7 +63,7 @@ const PlanSidebar = memo(function PlanSidebar({
activeProposedPlan,
markdownCwd,
workspaceRoot,
- use24HourTimestamps,
+ timestampFormat,
onClose,
}: PlanSidebarProps) {
const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
@@ -147,7 +148,7 @@ const PlanSidebar = memo(function PlanSidebar({
{activePlan ? (
- {formatTimestamp(activePlan.createdAt, use24HourTimestamps)}
+ {formatTimestamp(activePlan.createdAt, timestampFormat)}
) : null}
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index e84407b42a..0e6d470a02 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -20,6 +20,7 @@ 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;
@@ -45,7 +46,7 @@ interface MessagesTimelineProps {
onImageExpand: (preview: ExpandedImagePreview) => void;
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
- use24HourTimestamps: boolean;
+ timestampFormat: TimestampFormat;
workspaceRoot: string | undefined;
}
@@ -69,7 +70,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onImageExpand,
markdownCwd,
resolvedTheme,
- use24HourTimestamps,
+ timestampFormat,
workspaceRoot,
}: MessagesTimelineProps) {
const timelineRootRef = useRef(null);
@@ -427,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
)}
- {formatTimestamp(row.message.createdAt, use24HourTimestamps)}
+ {formatTimestamp(row.message.createdAt, timestampFormat)}
@@ -518,7 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
row.message.streaming
? formatElapsed(row.durationStart, nowIso)
: formatElapsed(row.durationStart, row.message.completedAt),
- use24HourTimestamps,
+ timestampFormat,
)}
@@ -657,10 +658,10 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null {
function formatMessageMeta(
createdAt: string,
duration: string | null,
- use24HourTimestamps: boolean,
+ timestampFormat: TimestampFormat,
): string {
- if (!duration) return formatTimestamp(createdAt, use24HourTimestamps);
- return `${formatTimestamp(createdAt, use24HourTimestamps)} • ${duration}`;
+ 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 7d88081f7a..6e64598e6f 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";
@@ -251,30 +258,39 @@ function SettingsRouteView() {
-
Use 24-hour timestamps
+
Timestamp format
- Show times like 13:42:09 instead of 1:42:09 PM.
+ Choose whether times render like 1:42:09 PM or{" "}
+ 13:42:09.
-
+ {
+ if (value !== "12-hour" && value !== "24-hour") return;
updateSettings({
- use24HourTimestamps: Boolean(checked),
- })
- }
- aria-label="Use 24-hour timestamps"
- />
+ timestampFormat: value,
+ });
+ }}
+ >
+
+
+
+
+ 12-hour
+ 24-hour
+
+
- {settings.use24HourTimestamps !== defaults.use24HourTimestamps ? (
+ {settings.timestampFormat !== defaults.timestampFormat ? (
updateSettings({
- use24HourTimestamps: defaults.use24HourTimestamps,
+ timestampFormat: defaults.timestampFormat,
})
}
>
diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts
index 3946783a14..da64dce775 100644
--- a/apps/web/src/timestampFormat.test.ts
+++ b/apps/web/src/timestampFormat.test.ts
@@ -4,7 +4,7 @@ import { getTimestampFormatOptions } from "./timestampFormat";
describe("getTimestampFormatOptions", () => {
it("builds a 12-hour formatter with seconds when requested", () => {
- expect(getTimestampFormatOptions(false, true)).toEqual({
+ expect(getTimestampFormatOptions("12-hour", true)).toEqual({
hour: "numeric",
minute: "2-digit",
second: "2-digit",
@@ -13,7 +13,7 @@ describe("getTimestampFormatOptions", () => {
});
it("builds a 24-hour formatter without seconds when requested", () => {
- expect(getTimestampFormatOptions(true, false)).toEqual({
+ 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
index 4e2c5db7c9..c50571deac 100644
--- a/apps/web/src/timestampFormat.ts
+++ b/apps/web/src/timestampFormat.ts
@@ -1,19 +1,28 @@
+import { type TimestampFormat } from "./appSettings";
+
+function uses24HourClock(timestampFormat: TimestampFormat): boolean {
+ return timestampFormat === "24-hour";
+}
+
export function getTimestampFormatOptions(
- use24Hour: boolean,
+ timestampFormat: TimestampFormat,
includeSeconds: boolean,
): Intl.DateTimeFormatOptions {
return {
hour: "numeric",
minute: "2-digit",
...(includeSeconds ? { second: "2-digit" } : {}),
- hour12: !use24Hour,
+ hour12: !uses24HourClock(timestampFormat),
};
}
const timestampFormatterCache = new Map();
-function getTimestampFormatter(use24Hour: boolean, includeSeconds: boolean): Intl.DateTimeFormat {
- const cacheKey = `${use24Hour ? "24" : "12"}:${includeSeconds ? "seconds" : "minutes"}`;
+function getTimestampFormatter(
+ timestampFormat: TimestampFormat,
+ includeSeconds: boolean,
+): Intl.DateTimeFormat {
+ const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`;
const cachedFormatter = timestampFormatterCache.get(cacheKey);
if (cachedFormatter) {
return cachedFormatter;
@@ -21,16 +30,16 @@ function getTimestampFormatter(use24Hour: boolean, includeSeconds: boolean): Int
const formatter = new Intl.DateTimeFormat(
undefined,
- getTimestampFormatOptions(use24Hour, includeSeconds),
+ getTimestampFormatOptions(timestampFormat, includeSeconds),
);
timestampFormatterCache.set(cacheKey, formatter);
return formatter;
}
-export function formatTimestamp(isoDate: string, use24Hour: boolean): string {
- return getTimestampFormatter(use24Hour, true).format(new Date(isoDate));
+export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
+ return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));
}
-export function formatShortTimestamp(isoDate: string, use24Hour: boolean): string {
- return getTimestampFormatter(use24Hour, false).format(new Date(isoDate));
+export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
+ return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
}
From 9c50801d1e1dc9b3b4ab8b83eea2e9ffb357badf Mon Sep 17 00:00:00 2001
From: huxcrux
Date: Thu, 12 Mar 2026 22:51:47 +0100
Subject: [PATCH 5/7] Add locale timestamp format option
---
apps/web/src/appSettings.test.ts | 21 ++-------
apps/web/src/appSettings.ts | 59 ++------------------------
apps/web/src/routes/_chat.settings.tsx | 9 ++--
apps/web/src/timestampFormat.test.ts | 8 ++++
apps/web/src/timestampFormat.ts | 16 ++++---
5 files changed, 31 insertions(+), 82 deletions(-)
diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts
index 0ffe6c0c00..326bceaacf 100644
--- a/apps/web/src/appSettings.test.ts
+++ b/apps/web/src/appSettings.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import {
+ DEFAULT_TIMESTAMP_FORMAT,
getAppModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
- resolveLegacyTimestampFormat,
} from "./appSettings";
describe("normalizeCustomModelSlugs", () => {
@@ -59,21 +59,8 @@ describe("resolveAppModelSelection", () => {
});
});
-describe("resolveLegacyTimestampFormat", () => {
- it("migrates legacy enabled booleans to 24-hour format", () => {
- expect(resolveLegacyTimestampFormat({ use24HourTimestamps: true })).toBe("24-hour");
- });
-
- it("migrates legacy disabled booleans to 12-hour format", () => {
- expect(resolveLegacyTimestampFormat({ use24HourTimestamps: false })).toBe("12-hour");
- });
-
- it("ignores legacy booleans when the new format is already present", () => {
- expect(
- resolveLegacyTimestampFormat({
- timestampFormat: "24-hour",
- use24HourTimestamps: false,
- }),
- ).toBeNull();
+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 03140b70ee..ac30989cb1 100644
--- a/apps/web/src/appSettings.ts
+++ b/apps/web/src/appSettings.ts
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from "react";
+import { useCallback } from "react";
import { Option, Schema } from "effect";
import { type ProviderKind } from "@t3tools/contracts";
import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
@@ -7,9 +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 = ["12-hour", "24-hour"] as const;
+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 = "12-hour";
+export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = {
codex: new Set(getModelOptions("codex").map((option) => option.slug)),
};
@@ -25,7 +25,7 @@ const AppSettingsSchema = Schema.Struct({
enableAssistantStreaming: Schema.Boolean.pipe(
Schema.withConstructorDefault(() => Option.some(false)),
),
- timestampFormat: Schema.Literals(["12-hour", "24-hour"]).pipe(
+ timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe(
Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)),
),
customCodexModels: Schema.Array(Schema.String).pipe(
@@ -139,38 +139,6 @@ export function resolveAppModelSelection(
);
}
-function parseStoredAppSettingsValue(raw: string | null): unknown {
- if (!raw) {
- return null;
- }
- try {
- return JSON.parse(raw) as unknown;
- } catch {
- return null;
- }
-}
-
-export function resolveLegacyTimestampFormat(value: unknown): TimestampFormat | null {
- if (!value || typeof value !== "object") {
- return null;
- }
-
- const candidate = value as Record;
- if (candidate.timestampFormat === "12-hour" || candidate.timestampFormat === "24-hour") {
- return null;
- }
-
- if (candidate.use24HourTimestamps === true) {
- return "24-hour";
- }
-
- if (candidate.use24HourTimestamps === false) {
- return "12-hour";
- }
-
- return null;
-}
-
export function useAppSettings() {
const [settings, setSettings] = useLocalStorage(
APP_SETTINGS_STORAGE_KEY,
@@ -192,25 +160,6 @@ export function useAppSettings() {
setSettings(DEFAULT_APP_SETTINGS);
}, [setSettings]);
- useEffect(() => {
- if (typeof window === "undefined") {
- return;
- }
-
- const rawSettings = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
- const legacyTimestampFormat = resolveLegacyTimestampFormat(
- parseStoredAppSettingsValue(rawSettings),
- );
- if (!legacyTimestampFormat || settings.timestampFormat === legacyTimestampFormat) {
- return;
- }
-
- setSettings((previous) => ({
- ...previous,
- timestampFormat: legacyTimestampFormat,
- }));
- }, [setSettings, settings.timestampFormat]);
-
return {
settings,
updateSettings,
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 6e64598e6f..70de0a40c5 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -260,23 +260,24 @@ function SettingsRouteView() {
Timestamp format
- Choose whether times render like 1:42:09 PM or{" "}
- 13:42:09.
+ Locale follows your browser or OS conventions. 12-hour and{" "}
+ 24-hour force the hour cycle.
{
- if (value !== "12-hour" && value !== "24-hour") return;
+ if (value !== "locale" && value !== "12-hour" && value !== "24-hour") return;
updateSettings({
timestampFormat: value,
});
}}
>
-
+
+ Locale
12-hour
24-hour
diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts
index da64dce775..f45ada7341 100644
--- a/apps/web/src/timestampFormat.test.ts
+++ b/apps/web/src/timestampFormat.test.ts
@@ -3,6 +3,14 @@ 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",
diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts
index c50571deac..d4ffa3c376 100644
--- a/apps/web/src/timestampFormat.ts
+++ b/apps/web/src/timestampFormat.ts
@@ -1,18 +1,22 @@
import { type TimestampFormat } from "./appSettings";
-function uses24HourClock(timestampFormat: TimestampFormat): boolean {
- return timestampFormat === "24-hour";
-}
-
export function getTimestampFormatOptions(
timestampFormat: TimestampFormat,
includeSeconds: boolean,
): Intl.DateTimeFormatOptions {
- return {
+ const baseOptions: Intl.DateTimeFormatOptions = {
hour: "numeric",
minute: "2-digit",
...(includeSeconds ? { second: "2-digit" } : {}),
- hour12: !uses24HourClock(timestampFormat),
+ };
+
+ if (timestampFormat === "locale") {
+ return baseOptions;
+ }
+
+ return {
+ ...baseOptions,
+ hour12: timestampFormat === "12-hour",
};
}
From caccb801c0a9aecefae34e641c742ec5b8d5e1fb Mon Sep 17 00:00:00 2001
From: huxcrux
Date: Thu, 12 Mar 2026 22:54:09 +0100
Subject: [PATCH 6/7] Rename locale timestamp label to system default
---
apps/web/src/routes/_chat.settings.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 70de0a40c5..9ebcd0a34e 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -260,8 +260,8 @@ function SettingsRouteView() {
Timestamp format
- Locale follows your browser or OS conventions. 12-hour and{" "}
- 24-hour force the hour cycle.
+ System default follows your browser or OS time format. 12-hour{" "}
+ and 24-hour force the hour cycle.
- Locale
+ System default
12-hour
24-hour
From f17bf3f0733ce5e39f965afaacfe96c8429d1fac Mon Sep 17 00:00:00 2001
From: huxcrux
Date: Thu, 12 Mar 2026 22:57:23 +0100
Subject: [PATCH 7/7] Fix timestamp format select label
---
apps/web/src/routes/_chat.settings.tsx | 14 ++++++++++----
1 file changed, 10 insertions(+), 4 deletions(-)
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 9ebcd0a34e..1007b3130f 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -56,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,
@@ -274,12 +280,12 @@ function SettingsRouteView() {
}}
>
-
+ {TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}
- System default
- 12-hour
- 24-hour
+ {TIMESTAMP_FORMAT_LABELS.locale}
+ {TIMESTAMP_FORMAT_LABELS["12-hour"]}
+ {TIMESTAMP_FORMAT_LABELS["24-hour"]}