Skip to content

Commit 727c0cf

Browse files
authored
feat: Allow Overriding Timestamp Format in Settings (pingdotgg#855)
1 parent dcd2e5c commit 727c0cf

10 files changed

Lines changed: 209 additions & 55 deletions

File tree

‎apps/web/src/appSettings.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22

33
import {
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+
62+
describe("timestamp format defaults", () => {
63+
it("defaults timestamp format to locale", () => {
64+
expect(DEFAULT_TIMESTAMP_FORMAT).toBe("locale");
65+
});
66+
});

‎apps/web/src/appSettings.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { useLocalStorage } from "./hooks/useLocalStorage";
77
const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
88
const MAX_CUSTOM_MODEL_COUNT = 32;
99
export const MAX_CUSTOM_MODEL_LENGTH = 256;
10+
export const TIMESTAMP_FORMAT_OPTIONS = ["locale", "12-hour", "24-hour"] as const;
11+
export type TimestampFormat = (typeof TIMESTAMP_FORMAT_OPTIONS)[number];
12+
export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale";
1013
const 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‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { buildPatchCacheKey } from "../lib/diffRendering";
2525
import { resolveDiffThemeName } from "../lib/diffRendering";
2626
import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries";
2727
import { useStore } from "../store";
28+
import { useAppSettings } from "../appSettings";
29+
import { formatShortTimestamp } from "../timestampFormat";
2830
import { ToggleGroup, Toggle } from "./ui/toggle-group";
2931

3032
type DiffRenderMode = "stacked" | "split";
@@ -149,13 +151,6 @@ function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string {
149151
return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`;
150152
}
151153

152-
function formatTurnChipTimestamp(isoDate: string): string {
153-
return new Intl.DateTimeFormat(undefined, {
154-
hour: "numeric",
155-
minute: "2-digit",
156-
}).format(new Date(isoDate));
157-
}
158-
159154
interface DiffPanelProps {
160155
mode?: "inline" | "sheet" | "sidebar";
161156
}
@@ -165,6 +160,7 @@ export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
165160
export 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‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { memo, useState, useCallback, useRef, useEffect } from "react";
2+
import { type TimestampFormat } from "../appSettings";
23
import { Badge } from "./ui/badge";
34
import { Button } from "./ui/button";
45
import { ScrollArea } from "./ui/scroll-area";
@@ -12,9 +13,9 @@ import {
1213
PanelRightCloseIcon,
1314
} from "lucide-react";
1415
import { cn } from "~/lib/utils";
15-
import { formatTimestamp } from "../session-logic";
1616
import type { ActivePlanState } from "../session-logic";
1717
import type { LatestProposedPlanState } from "../session-logic";
18+
import { formatTimestamp } from "../timestampFormat";
1819
import {
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‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
type VirtualItem,
66
useVirtualizer,
77
} from "@tanstack/react-virtual";
8-
import { deriveTimelineEntries, formatElapsed, formatTimestamp } from "../../session-logic";
8+
import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
99
import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll";
1010
import { type TurnDiffSummary } from "../../types";
1111
import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
@@ -20,6 +20,8 @@ import { ChangedFilesTree } from "./ChangedFilesTree";
2020
import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
2121
import { MessageCopyButton } from "./MessageCopyButton";
2222
import { computeMessageDurationStart } from "./MessagesTimeline.logic";
23+
import { type TimestampFormat } from "../../appSettings";
24+
import { formatTimestamp } from "../../timestampFormat";
2325

2426
const MAX_VISIBLE_WORK_LOG_ENTRIES = 6;
2527
const 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

653-
function formatMessageMeta(createdAt: string, duration: string | null): string {
654-
if (!duration) return formatTimestamp(createdAt);
655-
return `${formatTimestamp(createdAt)} • ${duration}`;
658+
function 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

658667
function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string {

‎apps/web/src/routes/_chat.settings.tsx‎

Lines changed: 91 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1111
import { ensureNativeApi } from "../nativeApi";
1212
import { Button } from "../components/ui/button";
1313
import { Input } from "../components/ui/input";
14+
import {
15+
Select,
16+
SelectItem,
17+
SelectPopup,
18+
SelectTrigger,
19+
SelectValue,
20+
} from "../components/ui/select";
1421
import { Switch } from "../components/ui/switch";
1522
import { APP_VERSION } from "../branding";
1623
import { SidebarInset } from "~/components/ui/sidebar";
@@ -49,6 +56,12 @@ const MODEL_PROVIDER_SETTINGS: Array<{
4956
},
5057
] as const;
5158

59+
const TIMESTAMP_FORMAT_LABELS = {
60+
locale: "System default",
61+
"12-hour": "12-hour",
62+
"24-hour": "24-hour",
63+
} as const;
64+
5265
function 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‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,6 @@ export type TimelineEntry =
8989
entry: WorkLogEntry;
9090
};
9191

92-
export 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-
10092
export 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`;

0 commit comments

Comments
 (0)