authorgravatar for 6117705+huxcrux@users.noreply.github.comHugo Blom <6117705+huxcrux@users.noreply.github.com> 2026-03-12 23:58:51+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 15:58:51-07:00
log727c0cfcbf1733edd9f23974f67c755ccbf6eee5
tree1f8018c9f73b2a71c8b7d554d5d038cbeb3c9b25
parentdcd2e5c34fbbf6cba7cdb23caef86568771a78d4
signature Signed by PGP key B5690EEEBB952194

feat: Allow Overriding Timestamp Format in Settings (#855)


10 files changed, 209 insertions(+), 55 deletions(-)

apps/web/src/appSettings.test.ts+7
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vitest";
22
33import {
4 DEFAULT_TIMESTAMP_FORMAT,
45 getAppModelOptions,
56 normalizeCustomModelSlugs,
67 resolveAppModelSelection,
......@@ -57,3 +58,9 @@ describe("resolveAppModelSelection", () => {
5758 expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
5859 });
5960});
61
62describe("timestamp format defaults", () => {
63 it("defaults timestamp format to locale", () => {
64 expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
65 });
66});
apps/web/src/appSettings.ts+6
......@@ -7,6 +7,9 @@ import { useLocalStorage } from "./hooks/useLocalStorage";
77const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
88const MAX_CUSTOM_MODEL_COUNT = 32;
99export const MAX_CUSTOM_MODEL_LENGTH = 256;
10export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const;
11export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
12export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
1013const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
1114 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
1215};
......@@ -22,6 +25,9 @@ const AppSettingsSchema = Schema.Struct({
2225 enableAssistantStreaming: Schema.Boolean.pipe(
2326 Schema.withConstructorDefault(() => Option.some(false)),
2427 ),
28 timestampFormat: Schema.Literals(["locale", "12-hour", "24-hour"]).pipe(
29 Schema.withConstructorDefault(() => Option.some(DEFAULT_TIMESTAMP_FORMAT)),
30 ),
2531 customCodexModels: Schema.Array(Schema.String).pipe(
2632 Schema.withConstructorDefault(() => Option.some([])),
2733 ),
apps/web/src/components/ChatView.tsx+3
......@@ -184,6 +184,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
184184 const setStoreThreadError = useStore((store) => store.setError);
185185 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
186186 const { settings } = useAppSettings();
187 const timestampFormat = settings.timestampFormat;
187188 const navigate = useNavigate();
188189 const rawSearch = useSearch({
189190 strict: false,
......@@ -3244,6 +3245,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
32443245 onImageExpand={onExpandTimelineImage}
32453246 markdownCwd={gitCwd ?? undefined}
32463247 resolvedTheme={resolvedTheme}
3248 timestampFormat={timestampFormat}
32473249 workspaceRoot={activeProject?.cwd ?? undefined}
32483250 />
32493251 </div>
......@@ -3764,6 +3766,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
37643766 activeProposedPlan={activeProposedPlan}
37653767 markdownCwd={gitCwd ?? undefined}
37663768 workspaceRoot={activeProject?.cwd ?? undefined}
3769 timestampFormat={timestampFormat}
37673770 onClose={() => {
37683771 setPlanSidebarOpen(false);
37693772 // Track that the user explicitly dismissed for this turn so auto-open won't fight them.
apps/web/src/components/DiffPanel.tsx+4-8
......@@ -25,6 +25,8 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
2525import { resolveDiffThemeName } from "../lib/diffRendering";
2626import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
2727import { useStore } from "../store";
28import { useAppSettings } from "../appSettings";
29import { formatShortTimestamp } from "../timestampFormat";
2830import { ToggleGroup, Toggle } from "./ui/toggle-group";
2931
3032type DiffRenderMode = "stacked" | "split";
......@@ -149,13 +151,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
149151 return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
150152}
151153
152function formatTurnChipTimestamp(isoDate: string): string {
153 return new Intl.DateTimeFormat(undefined, {
154 hour: "numeric",
155 minute: "2-digit",
156 }).format(new Date(isoDate));
157}
158
159154interface DiffPanelProps {
160155 mode?: "inline" | "sheet" | "sidebar";
161156}
......@@ -165,6 +160,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
165160export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
166161 const navigate = useNavigate();
167162 const { resolvedTheme } = useTheme();
163 const { settings } = useAppSettings();
168164 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
169165 const patchViewportRef = useRef<HTMLDivElement>(null);
170166 const turnStripRef = useRef<HTMLDivElement>(null);
......@@ -487,7 +483,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
487483 "?"}
488484 </span>
489485 <span className="text-[9px] leading-tight opacity-70">
490 {formatTurnChipTimestamp(summary.completedAt)}
486 {formatShortTimestamp(summary.completedAt, settings.timestampFormat)}
491487 </span>
492488 </div>
493489 </div>
apps/web/src/components/PlanSidebar.tsx+5-2
......@@ -1,4 +1,5 @@
11import { memo, useState, useCallback, useRef, useEffect } from "react";
2import { type TimestampFormat } from "../appSettings";
23import { Badge } from "./ui/badge";
34import { Button } from "./ui/button";
45import { ScrollArea } from "./ui/scroll-area";
......@@ -12,9 +13,9 @@ import {
1213 PanelRightCloseIcon,
1314} from "lucide-react";
1415import { cn } from "~/lib/utils";
15import { formatTimestamp } from "../session-logic";
1616import type { ActivePlanState } from "../session-logic";
1717import type { LatestProposedPlanState } from "../session-logic";
18import { formatTimestamp } from "../timestampFormat";
1819import {
1920 proposedPlanTitle,
2021 buildProposedPlanMarkdownFilename,
......@@ -53,6 +54,7 @@ interface PlanSidebarProps {
5354 activeProposedPlan: LatestProposedPlanState | null;
5455 markdownCwd: string | undefined;
5556 workspaceRoot: string | undefined;
57 timestampFormat: TimestampFormat;
5658 onClose: () => void;
5759}
5860
......@@ -61,6 +63,7 @@ const PlanSidebar = memo(function PlanSidebar({
6163 activeProposedPlan,
6264 markdownCwd,
6365 workspaceRoot,
66 timestampFormat,
6467 onClose,
6568}: PlanSidebarProps) {
6669 const [proposedPlanExpanded, setProposedPlanExpanded] = useState(false);
......@@ -145,7 +148,7 @@ const PlanSidebar = memo(function PlanSidebar({
145148 </Badge>
146149 {activePlan ? (
147150 <span className="text-[11px] text-muted-foreground/60">
148 {formatTimestamp(activePlan.createdAt)}
151 {formatTimestamp(activePlan.createdAt, timestampFormat)}
149152 </span>
150153 ) : null}
151154 </div>
apps/web/src/components/chat/MessagesTimeline.tsx+14-5
......@@ -5,7 +5,7 @@ import {
55 type VirtualItem,
66 useVirtualizer,
77} from "@tanstack/react-virtual";
8import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic";
8import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
99import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1010import { type TurnDiffSummary } from "../../types";
1111import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
......@@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree";
2020import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
2121import { MessageCopyButton } from "./MessageCopyButton";
2222import { computeMessageDurationStart } from "./MessagesTimeline.logic";
23import { type TimestampFormat } from "../../appSettings";
24import { formatTimestamp } from "../../timestampFormat";
2325
2426const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
2527const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;
......@@ -44,6 +46,7 @@ interface MessagesTimelineProps {
4446 onImageExpand: (preview: ExpandedImagePreview) => void;
4547 markdownCwd: string | undefined;
4648 resolvedTheme: "light" | "dark";
49 timestampFormat: TimestampFormat;
4750 workspaceRoot: string | undefined;
4851}
4952
......@@ -67,6 +70,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
6770 onImageExpand,
6871 markdownCwd,
6972 resolvedTheme,
73 timestampFormat,
7074 workspaceRoot,
7175}: MessagesTimelineProps) {
7276 const timelineRootRef = useRef<HTMLDivElement | null>(null);
......@@ -424,7 +428,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
424428 )}
425429 </div>
426430 <p className="text-right text-[10px] text-muted-foreground/30">
427 {formatTimestamp(row.message.createdAt)}
431 {formatTimestamp(row.message.createdAt, timestampFormat)}
428432 </p>
429433 </div>
430434 </div>
......@@ -515,6 +519,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
515519 row.message.streaming
516520 ? formatElapsed(row.durationStart, nowIso)
517521 : formatElapsed(row.durationStart, row.message.completedAt),
522 timestampFormat,
518523 )}
519524 </p>
520525 </div>
......@@ -650,9 +655,13 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null {
650655 return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
651656}
652657
653function formatMessageMeta(createdAt: string, duration: string | null): string {
654 if (!duration) return formatTimestamp(createdAt);
655 return `${formatTimestamp(createdAt)} • ${duration}`;
658function formatMessageMeta(
659 createdAt: string,
660 duration: string | null,
661 timestampFormat: TimestampFormat,
662): string {
663 if (!duration) return formatTimestamp(createdAt, timestampFormat);
664 return `${formatTimestamp(createdAt, timestampFormat)} • ${duration}`;
656665}
657666
658667function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {
apps/web/src/routes/_chat.settings.tsx+91-32
......@@ -11,6 +11,13 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1111import { ensureNativeApi } from "../nativeApi";
1212import { Button } from "../components/ui/button";
1313import { Input } from "../components/ui/input";
14import {
15 Select,
16 SelectItem,
17 SelectPopup,
18 SelectTrigger,
19 SelectValue,
20} from "../components/ui/select";
1421import { Switch } from "../components/ui/switch";
1522import { APP_VERSION } from "../branding";
1623import { SidebarInset } from "~/components/ui/sidebar";
......@@ -49,6 +56,12 @@ const MODEL_PROVIDER_SETTINGS: Array<{
4956 },
5057] as const;
5158
59const TIMESTAMP_FORMAT_LABELS = {
60 locale: "System default",
61 "12-hour": "12-hour",
62 "24-hour": "24-hour",
63} as const;
64
5265function getCustomModelsForProvider(
5366 settings: ReturnType<typeof useAppSettings>["settings"],
5467 provider: ProviderKind,
......@@ -210,43 +223,89 @@ function SettingsRouteView() {
210223 <div className="mb-4">
211224 <h2 className="text-sm font-medium text-foreground">Appearance</h2>
212225 <p className="mt-1 text-xs text-muted-foreground">
213 Choose how T3 Code handles light and dark mode.
226 Choose how T3 Code looks across the app.
214227 </p>
215228 </div>
216229
217 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
218 {THEME_OPTIONS.map((option) => {
219 const selected = theme === option.value;
220 return (
221 <button
222 key={option.value}
223 type="button"
224 role="radio"
225 aria-checked={selected}
226 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
227 selected
228 ? "border-primary/60 bg-primary/8 text-foreground"
229 : "border-border bg-background text-muted-foreground hover:bg-accent"
230 }`}
231 onClick={() => setTheme(option.value)}
232 >
233 <span className="flex flex-col">
234 <span className="text-sm font-medium">{option.label}</span>
235 <span className="text-xs">{option.description}</span>
236 </span>
237 {selected ? (
238 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
239 Selected
230 <div className="space-y-4">
231 <div className="space-y-2" role="radiogroup" aria-label="Theme preference">
232 {THEME_OPTIONS.map((option) => {
233 const selected = theme === option.value;
234 return (
235 <button
236 key={option.value}
237 type="button"
238 role="radio"
239 aria-checked={selected}
240 className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${
241 selected
242 ? "border-primary/60 bg-primary/8 text-foreground"
243 : "border-border bg-background text-muted-foreground hover:bg-accent"
244 }`}
245 onClick={() => setTheme(option.value)}
246 >
247 <span className="flex flex-col">
248 <span className="text-sm font-medium">{option.label}</span>
249 <span className="text-xs">{option.description}</span>
240250 </span>
241 ) : null}
242 </button>
243 );
244 })}
245 </div>
251 {selected ? (
252 <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary">
253 Selected
254 </span>
255 ) : null}
256 </button>
257 );
258 })}
259 </div>
246260
247 <p className="mt-4 text-xs text-muted-foreground">
248 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
249 </p>
261 <p className="text-xs text-muted-foreground">
262 Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span>
263 </p>
264
265 <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2">
266 <div>
267 <p className="text-sm font-medium text-foreground">Timestamp format</p>
268 <p className="text-xs text-muted-foreground">
269 System default follows your browser or OS time format. <code>12-hour</code>{" "}
270 and <code>24-hour</code> force the hour cycle.
271 </p>
272 </div>
273 <Select
274 value={settings.timestampFormat}
275 onValueChange={(value) => {
276 if (value !== "locale" && value !== "12-hour" && value !== "24-hour") return;
277 updateSettings({
278 timestampFormat: value,
279 });
280 }}
281 >
282 <SelectTrigger className="w-40" aria-label="Timestamp format">
283 <SelectValue>{TIMESTAMP_FORMAT_LABELS[settings.timestampFormat]}</SelectValue>
284 </SelectTrigger>
285 <SelectPopup align="end">
286 <SelectItem value="locale">{TIMESTAMP_FORMAT_LABELS.locale}</SelectItem>
287 <SelectItem value="12-hour">{TIMESTAMP_FORMAT_LABELS["12-hour"]}</SelectItem>
288 <SelectItem value="24-hour">{TIMESTAMP_FORMAT_LABELS["24-hour"]}</SelectItem>
289 </SelectPopup>
290 </Select>
291 </div>
292
293 {settings.timestampFormat !== defaults.timestampFormat ? (
294 <div className="flex justify-end">
295 <Button
296 size="xs"
297 variant="outline"
298 onClick={() =>
299 updateSettings({
300 timestampFormat: defaults.timestampFormat,
301 })
302 }
303 >
304 Restore default
305 </Button>
306 </div>
307 ) : null}
308 </div>
250309 </section>
251310
252311 <section className="rounded-2xl border border-border bg-card p-5">
apps/web/src/session-logic.ts-8
......@@ -89,14 +89,6 @@ export type TimelineEntry =
8989 entry: WorkLogEntry;
9090 };
9191
92export function formatTimestamp(isoDate: string): string {
93 return new Intl.DateTimeFormat(undefined, {
94 hour: "numeric",
95 minute: "2-digit",
96 second: "2-digit",
97 }).format(new Date(isoDate));
98}
99
10092export function formatDuration(durationMs: number): string {
10193 if (!Number.isFinite(durationMs) || durationMs < 0) return "0ms";
10294 if (durationMs < 1_000) return `${Math.max(1, Math.round(durationMs))}ms`;
apps/web/src/timestampFormat.test.ts created+30
......@@ -0,0 +1,30 @@
1import { describe, expect, it } from "vitest";
2
3import { getTimestampFormatOptions } from "./timestampFormat";
4
5describe("getTimestampFormatOptions", () => {
6 it("omits hour12 when locale formatting is requested", () => {
7 expect(getTimestampFormatOptions("locale", true)).toEqual({
8 hour: "numeric",
9 minute: "2-digit",
10 second: "2-digit",
11 });
12 });
13
14 it("builds a 12-hour formatter with seconds when requested", () => {
15 expect(getTimestampFormatOptions("12-hour", true)).toEqual({
16 hour: "numeric",
17 minute: "2-digit",
18 second: "2-digit",
19 hour12: true,
20 });
21 });
22
23 it("builds a 24-hour formatter without seconds when requested", () => {
24 expect(getTimestampFormatOptions("24-hour", false)).toEqual({
25 hour: "numeric",
26 minute: "2-digit",
27 hour12: false,
28 });
29 });
30});
apps/web/src/timestampFormat.ts created+49
......@@ -0,0 +1,49 @@
1import { type TimestampFormat } from "./appSettings";
2
3export function getTimestampFormatOptions(
4 timestampFormat: TimestampFormat,
5 includeSeconds: boolean,
6): Intl.DateTimeFormatOptions {
7 const baseOptions: Intl.DateTimeFormatOptions = {
8 hour: "numeric",
9 minute: "2-digit",
10 ...(includeSeconds ? { second: "2-digit" } : {}),
11 };
12
13 if (timestampFormat === "locale") {
14 return baseOptions;
15 }
16
17 return {
18 ...baseOptions,
19 hour12: timestampFormat === "12-hour",
20 };
21}
22
23const timestampFormatterCache = new Map<string, Intl.DateTimeFormat>();
24
25function getTimestampFormatter(
26 timestampFormat: TimestampFormat,
27 includeSeconds: boolean,
28): Intl.DateTimeFormat {
29 const cacheKey = `${timestampFormat}:${includeSeconds ? "seconds" : "minutes"}`;
30 const cachedFormatter = timestampFormatterCache.get(cacheKey);
31 if (cachedFormatter) {
32 return cachedFormatter;
33 }
34
35 const formatter = new Intl.DateTimeFormat(
36 undefined,
37 getTimestampFormatOptions(timestampFormat, includeSeconds),
38 );
39 timestampFormatterCache.set(cacheKey, formatter);
40 return formatter;
41}
42
43export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
44 return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate));
45}
46
47export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string {
48 return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate));
49}