diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 9a17834ced7..4a578937524 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -193,6 +193,16 @@ export const globalSettingsSchema = z.object({ * @default "send" */ enterBehavior: z.enum(["send", "newline"]).optional(), + /** + * Whether to show timestamps on chat messages + * @default false + */ + showTimestamps: z.boolean().optional(), + /** + * Format for displaying timestamps on chat messages + * @default "24hour" + */ + timestampFormat: z.enum(["12hour", "24hour"]).optional(), profileThresholds: z.record(z.string(), z.number()).optional(), hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c88..940cbaec430 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -286,6 +286,8 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "enterBehavior" + | "showTimestamps" + | "timestampFormat" | "includeCurrentTime" | "includeCurrentCost" | "maxGitStatusFiles" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ef2231b26d1..4d61c6f4583 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -15,6 +15,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" import { findMatchingResourceOrTemplate } from "@src/utils/mcp" import { vscode } from "@src/utils/vscode" import { formatPathTooltip } from "@src/utils/formatPathTooltip" +import { formatTimestamp, TimestampFormat } from "@src/utils/formatTimestamp" import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock" @@ -167,7 +168,16 @@ export const ChatRowContent = ({ }: ChatRowContentProps) => { const { t, i18n } = useTranslation() - const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration, clineMessages } = useExtensionState() + const { + mcpServers, + alwaysAllowMcp, + currentCheckpoint, + mode, + apiConfiguration, + clineMessages, + showTimestamps, + timestampFormat, + } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) const [isEditing, setIsEditing] = useState(false) const [editedContent, setEditedContent] = useState("") @@ -380,6 +390,13 @@ export const ChatRowContent = ({ wordBreak: "break-word", } + // Timestamp element to be displayed on the right side of headers + const timestampElement = showTimestamps ? ( + + {formatTimestamp(message.ts, (timestampFormat ?? "24hour") as TimestampFormat)} + + ) : null + const tool = useMemo( () => (message.ask === "tool" ? safeJsonParse(message.text) : null), [message.ask, message.text], @@ -1200,6 +1217,7 @@ export const ChatRowContent = ({
{t("chat:text.rooSaid")} + {timestampElement}
@@ -1219,6 +1237,7 @@ export const ChatRowContent = ({
{t("chat:feedback.youSaid")} + {timestampElement}
{icon} {title} + {timestampElement}
diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 6331f13edf9..c98d98f899f 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -209,6 +209,8 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, enterBehavior, + showTimestamps, + timestampFormat, includeCurrentTime, includeCurrentCost, maxGitStatusFiles, @@ -411,6 +413,8 @@ const SettingsView = forwardRef(({ onDone, t includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", + showTimestamps: showTimestamps ?? false, + timestampFormat: timestampFormat ?? "24hour", includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -830,6 +834,8 @@ const SettingsView = forwardRef(({ onDone, t )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index b4e5a4e861a..5a67ffb9618 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -1,6 +1,6 @@ import { HTMLAttributes, useMemo } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" -import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" import { Glasses } from "lucide-react" import { telemetryClient } from "@/utils/TelemetryClient" @@ -12,12 +12,16 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext" interface UISettingsProps extends HTMLAttributes { reasoningBlockCollapsed: boolean enterBehavior: "send" | "newline" + showTimestamps: boolean + timestampFormat: "12hour" | "24hour" setCachedStateField: SetCachedStateField } export const UISettings = ({ reasoningBlockCollapsed, enterBehavior, + showTimestamps, + timestampFormat, setCachedStateField, ...props }: UISettingsProps) => { @@ -48,6 +52,24 @@ export const UISettings = ({ }) } + const handleShowTimestampsChange = (value: boolean) => { + setCachedStateField("showTimestamps", value) + + // Track telemetry event + telemetryClient.capture("ui_settings_show_timestamps_changed", { + enabled: value, + }) + } + + const handleTimestampFormatChange = (format: "12hour" | "24hour") => { + setCachedStateField("timestampFormat", format) + + // Track telemetry event + telemetryClient.capture("ui_settings_timestamp_format_changed", { + format, + }) + } + return (
@@ -86,6 +108,44 @@ export const UISettings = ({ {t("settings:ui.requireCtrlEnterToSend.description", { primaryMod })}
+ + {/* Show Timestamps Setting */} +
+ handleShowTimestampsChange(e.target.checked)} + data-testid="show-timestamps-checkbox"> + {t("settings:ui.showTimestamps.label")} + +
+ {t("settings:ui.showTimestamps.description")} +
+
+ + {/* Timestamp Format Setting - only visible when timestamps are enabled */} + {showTimestamps && ( +
+
+ {t("settings:ui.timestampFormat.label")} + + handleTimestampFormatChange(e.target.value as "12hour" | "24hour") + } + data-testid="timestamp-format-dropdown"> + + {t("settings:ui.timestampFormat.options.24hour")} + + + {t("settings:ui.timestampFormat.options.12hour")} + + +
+
+ {t("settings:ui.timestampFormat.description")} +
+
+ )}
diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 2a21a410b38..bd302196883 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -6,6 +6,8 @@ describe("UISettings", () => { const defaultProps = { reasoningBlockCollapsed: false, enterBehavior: "send" as const, + showTimestamps: false, + timestampFormat: "24hour" as const, setCachedStateField: vi.fn(), } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 3fe5340bdbc..85a13449573 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -163,6 +163,10 @@ export interface ExtensionStateContextType extends ExtensionState { setIncludeCurrentTime: (value: boolean) => void includeCurrentCost?: boolean setIncludeCurrentCost: (value: boolean) => void + showTimestamps?: boolean + setShowTimestamps: (value: boolean) => void + timestampFormat?: "12hour" | "24hour" + setTimestampFormat: (value: "12hour" | "24hour") => void } export const ExtensionStateContext = createContext(undefined) @@ -297,6 +301,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [prevCloudIsAuthenticated, setPrevCloudIsAuthenticated] = useState(false) const [includeCurrentTime, setIncludeCurrentTime] = useState(true) const [includeCurrentCost, setIncludeCurrentCost] = useState(true) + const [showTimestamps, setShowTimestamps] = useState(false) // Default to false (timestamps hidden) + const [timestampFormat, setTimestampFormat] = useState<"12hour" | "24hour">("24hour") // Default to 24-hour format const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -342,6 +348,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode if ((newState as any).includeCurrentCost !== undefined) { setIncludeCurrentCost((newState as any).includeCurrentCost) } + // Update showTimestamps if present in state message + if ((newState as any).showTimestamps !== undefined) { + setShowTimestamps((newState as any).showTimestamps) + } + // Update timestampFormat if present in state message + if ((newState as any).timestampFormat !== undefined) { + setTimestampFormat((newState as any).timestampFormat) + } // Handle marketplace data if present in state message if (newState.marketplaceItems !== undefined) { setMarketplaceItems(newState.marketplaceItems) @@ -592,6 +606,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setIncludeCurrentTime, includeCurrentCost, setIncludeCurrentCost, + showTimestamps, + setShowTimestamps, + timestampFormat, + setTimestampFormat, } return {children} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index b836ecbfc87..47d8ed73024 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -68,6 +68,18 @@ "requireCtrlEnterToSend": { "label": "Require {{primaryMod}}+Enter to send messages", "description": "When enabled, you must press {{primaryMod}}+Enter to send messages instead of just Enter" + }, + "showTimestamps": { + "label": "Show timestamps on messages", + "description": "When enabled, timestamps will be displayed on the right side of message headers" + }, + "timestampFormat": { + "label": "Time format", + "description": "Choose how timestamps are displayed", + "options": { + "12hour": "12-hour (2:34 PM)", + "24hour": "24-hour (14:34)" + } } }, "prompts": { diff --git a/webview-ui/src/utils/__tests__/formatTimestamp.spec.ts b/webview-ui/src/utils/__tests__/formatTimestamp.spec.ts new file mode 100644 index 00000000000..9e815e7b6bc --- /dev/null +++ b/webview-ui/src/utils/__tests__/formatTimestamp.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { formatTimestamp } from "../formatTimestamp" + +describe("formatTimestamp", () => { + beforeEach(() => { + // Mock current date to 2026-01-09 14:30:00 + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-09T14:30:00.000Z")) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe("24-hour format (default)", () => { + it("formats today's time in 24-hour format", () => { + // Same day at 10:15 + const timestamp = new Date("2026-01-09T10:15:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("10:15") + }) + + it("pads single-digit hours and minutes", () => { + const timestamp = new Date("2026-01-09T09:05:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("09:05") + }) + + it("includes date for messages from previous days", () => { + // Previous day + const timestamp = new Date("2026-01-08T14:34:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("Jan 8, 14:34") + }) + + it("includes date for messages from previous months", () => { + // Previous month + const timestamp = new Date("2025-12-25T09:00:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("Dec 25, 09:00") + }) + + it("includes date for messages from previous years", () => { + // Previous year + const timestamp = new Date("2025-06-15T18:45:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("Jun 15, 18:45") + }) + + it("handles midnight correctly", () => { + const timestamp = new Date("2026-01-09T00:00:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("00:00") + }) + + it("handles end of day correctly", () => { + const timestamp = new Date("2026-01-09T23:59:00.000Z").getTime() + expect(formatTimestamp(timestamp)).toBe("23:59") + }) + + it("correctly abbreviates all months", () => { + const months = [ + { date: "2025-01-15", expected: "Jan" }, + { date: "2025-02-15", expected: "Feb" }, + { date: "2025-03-15", expected: "Mar" }, + { date: "2025-04-15", expected: "Apr" }, + { date: "2025-05-15", expected: "May" }, + { date: "2025-06-15", expected: "Jun" }, + { date: "2025-07-15", expected: "Jul" }, + { date: "2025-08-15", expected: "Aug" }, + { date: "2025-09-15", expected: "Sep" }, + { date: "2025-10-15", expected: "Oct" }, + { date: "2025-11-15", expected: "Nov" }, + { date: "2025-12-15", expected: "Dec" }, + ] + + months.forEach(({ date, expected }) => { + const timestamp = new Date(`${date}T12:00:00.000Z`).getTime() + expect(formatTimestamp(timestamp)).toContain(expected) + }) + }) + }) + + describe("12-hour format", () => { + it("formats morning time with AM", () => { + const timestamp = new Date("2026-01-09T10:15:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("10:15 AM") + }) + + it("formats afternoon time with PM", () => { + const timestamp = new Date("2026-01-09T14:30:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("2:30 PM") + }) + + it("formats midnight as 12:00 AM", () => { + const timestamp = new Date("2026-01-09T00:00:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("12:00 AM") + }) + + it("formats noon as 12:00 PM", () => { + const timestamp = new Date("2026-01-09T12:00:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("12:00 PM") + }) + + it("formats end of day correctly", () => { + const timestamp = new Date("2026-01-09T23:59:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("11:59 PM") + }) + + it("includes date for messages from previous days with 12-hour format", () => { + const timestamp = new Date("2026-01-08T14:34:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("Jan 8, 2:34 PM") + }) + + it("includes date for messages from previous months with 12-hour format", () => { + const timestamp = new Date("2025-12-25T09:00:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("Dec 25, 9:00 AM") + }) + + it("handles single-digit hours without padding", () => { + const timestamp = new Date("2026-01-09T09:05:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("9:05 AM") + }) + + it("formats 1 AM correctly", () => { + const timestamp = new Date("2026-01-09T01:00:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("1:00 AM") + }) + + it("formats 1 PM correctly", () => { + const timestamp = new Date("2026-01-09T13:00:00.000Z").getTime() + expect(formatTimestamp(timestamp, "12hour")).toBe("1:00 PM") + }) + }) + + describe("explicit 24-hour format parameter", () => { + it("formats time same as default when explicitly set to 24hour", () => { + const timestamp = new Date("2026-01-09T14:30:00.000Z").getTime() + expect(formatTimestamp(timestamp, "24hour")).toBe("14:30") + }) + + it("formats midnight correctly with explicit 24-hour format", () => { + const timestamp = new Date("2026-01-09T00:00:00.000Z").getTime() + expect(formatTimestamp(timestamp, "24hour")).toBe("00:00") + }) + }) +}) diff --git a/webview-ui/src/utils/formatTimestamp.ts b/webview-ui/src/utils/formatTimestamp.ts new file mode 100644 index 00000000000..9ddf68dd619 --- /dev/null +++ b/webview-ui/src/utils/formatTimestamp.ts @@ -0,0 +1,61 @@ +export type TimestampFormat = "12hour" | "24hour" + +/** + * Formats a Unix timestamp (in milliseconds) to a human-readable time string. + * + * Requirements from Issue #10539: + * - Configurable 12-hour (2:34 PM) or 24-hour (14:34) format + * - Full date for messages from previous days (e.g., "Jan 7, 14:34" or "Jan 7, 2:34 PM") + * - Text-size same as header row text + * + * @param ts - Unix timestamp in milliseconds + * @param format - Time format: "12hour" for AM/PM, "24hour" for 24-hour format (default: "24hour") + * @returns Formatted time string + */ +export function formatTimestamp(ts: number, format: TimestampFormat = "24hour"): string { + const date = new Date(ts) + const now = new Date() + + // Check if the message is from today + const isToday = + date.getDate() === now.getDate() && + date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear() + + // Format the time based on the selected format + const time = formatTime(date, format) + + if (isToday) { + // Just show time for today's messages + return time + } + + // For older messages, show abbreviated month, day, and time + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + const month = months[date.getMonth()] + const day = date.getDate() + + return `${month} ${day}, ${time}` +} + +/** + * Formats just the time portion of a date. + * + * @param date - Date object to format + * @param format - Time format: "12hour" for AM/PM, "24hour" for 24-hour format + * @returns Formatted time string + */ +function formatTime(date: Date, format: TimestampFormat): string { + const hours24 = date.getHours() + const minutes = date.getMinutes().toString().padStart(2, "0") + + if (format === "12hour") { + const hours12 = hours24 % 12 || 12 // Convert 0 to 12 for midnight + const period = hours24 < 12 ? "AM" : "PM" + return `${hours12}:${minutes} ${period}` + } + + // 24-hour format + const hours = hours24.toString().padStart(2, "0") + return `${hours}:${minutes}` +}