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

- 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 ? ( -
- + aria-label="Use 24-hour timestamps" + />
- ) : null} + + {settings.use24HourTimestamps !== defaults.use24HourTimestamps ? ( +
+ +
+ ) : 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.

- +
- {settings.use24HourTimestamps !== defaults.use24HourTimestamps ? ( + {settings.timestampFormat !== defaults.timestampFormat ? (