feat: add configurable timestamps to chat messages

Implements Issue #10539 - Adds the ability to display timestamps on chat messages.

Features:
- 24-hour format (14:34)
- Full date for messages from previous days (e.g., "Jan 7, 14:34")
- Configurable on/off toggle in UI Settings
- Timestamps appear on the right side of header rows
- Same styling as other header elements

Files changed:
- packages/types/src/global-settings.ts: Add showTimestamps setting
- src/shared/ExtensionMessage.ts: Add to ExtensionState
- webview-ui/src/context/ExtensionStateContext.tsx: Add state and setter
- webview-ui/src/utils/formatTimestamp.ts: New timestamp formatting utility
- webview-ui/src/components/settings/UISettings.tsx: Add toggle
- webview-ui/src/components/settings/SettingsView.tsx: Pass prop
- webview-ui/src/components/chat/ChatRow.tsx: Display timestamps
- webview-ui/src/i18n/locales/en/settings.json: Translation strings
- webview-ui/src/utils/__tests__/formatTimestamp.spec.ts: Tests
- webview-ui/src/components/settings/__tests__/UISettings.spec.tsx: Update tests
This commit is contained in:
Roo Code 2026-01-09 04:52:22 +00:00
parent 9ac449919e
commit 58f707b23e
10 changed files with 172 additions and 1 deletions

View file

@ -193,6 +193,11 @@ 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(),
profileThresholds: z.record(z.string(), z.number()).optional(),
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),

View file

@ -286,6 +286,7 @@ export type ExtensionState = Pick<
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "enterBehavior"
| "showTimestamps"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"

View file

@ -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 } from "@src/utils/formatTimestamp"
import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock"
import UpdateTodoListToolBlock from "./UpdateTodoListToolBlock"
@ -167,7 +168,8 @@ export const ChatRowContent = ({
}: ChatRowContentProps) => {
const { t, i18n } = useTranslation()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration, clineMessages } = useExtensionState()
const { mcpServers, alwaysAllowMcp, currentCheckpoint, mode, apiConfiguration, clineMessages, showTimestamps } =
useExtensionState()
const { info: model } = useSelectedModel(apiConfiguration)
const [isEditing, setIsEditing] = useState(false)
const [editedContent, setEditedContent] = useState("")
@ -380,6 +382,13 @@ export const ChatRowContent = ({
wordBreak: "break-word",
}
// Timestamp element to be displayed on the right side of headers
const timestampElement = showTimestamps ? (
<span className="text-vscode-descriptionForeground ml-auto shrink-0" style={{ fontWeight: "normal" }}>
{formatTimestamp(message.ts)}
</span>
) : null
const tool = useMemo(
() => (message.ask === "tool" ? safeJsonParse<ClineSayTool>(message.text) : null),
[message.ask, message.text],
@ -1200,6 +1209,7 @@ export const ChatRowContent = ({
<div style={headerStyle}>
<MessageCircle className="w-4 shrink-0" aria-label="Speech bubble icon" />
<span style={{ fontWeight: "bold" }}>{t("chat:text.rooSaid")}</span>
{timestampElement}
</div>
<div className="pl-6">
<Markdown markdown={message.text} partial={message.partial} />
@ -1219,6 +1229,7 @@ export const ChatRowContent = ({
<div style={headerStyle}>
<User className="w-4 shrink-0" aria-label="User icon" />
<span style={{ fontWeight: "bold" }}>{t("chat:feedback.youSaid")}</span>
{timestampElement}
</div>
<div
className={cn(
@ -1336,6 +1347,7 @@ export const ChatRowContent = ({
<div style={headerStyle}>
{icon}
{title}
{timestampElement}
</div>
<div className="border-l border-green-600/30 ml-2 pl-4 pb-1">
<Markdown markdown={message.text} />

View file

@ -209,6 +209,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
enterBehavior,
showTimestamps,
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
@ -830,6 +831,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
enterBehavior={enterBehavior ?? "send"}
showTimestamps={showTimestamps ?? false}
setCachedStateField={setCachedStateField}
/>
)}

View file

@ -12,12 +12,14 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
reasoningBlockCollapsed: boolean
enterBehavior: "send" | "newline"
showTimestamps: boolean
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}
export const UISettings = ({
reasoningBlockCollapsed,
enterBehavior,
showTimestamps,
setCachedStateField,
...props
}: UISettingsProps) => {
@ -48,6 +50,15 @@ export const UISettings = ({
})
}
const handleShowTimestampsChange = (value: boolean) => {
setCachedStateField("showTimestamps", value)
// Track telemetry event
telemetryClient.capture("ui_settings_show_timestamps_changed", {
enabled: value,
})
}
return (
<div {...props}>
<SectionHeader>
@ -86,6 +97,19 @@ export const UISettings = ({
{t("settings:ui.requireCtrlEnterToSend.description", { primaryMod })}
</div>
</div>
{/* Show Timestamps Setting */}
<div className="flex flex-col gap-1">
<VSCodeCheckbox
checked={showTimestamps}
onChange={(e: any) => handleShowTimestampsChange(e.target.checked)}
data-testid="show-timestamps-checkbox">
<span className="font-medium">{t("settings:ui.showTimestamps.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.showTimestamps.description")}
</div>
</div>
</div>
</Section>
</div>

View file

@ -6,6 +6,7 @@ describe("UISettings", () => {
const defaultProps = {
reasoningBlockCollapsed: false,
enterBehavior: "send" as const,
showTimestamps: false,
setCachedStateField: vi.fn(),
}

View file

@ -163,6 +163,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setIncludeCurrentTime: (value: boolean) => void
includeCurrentCost?: boolean
setIncludeCurrentCost: (value: boolean) => void
showTimestamps?: boolean
setShowTimestamps: (value: boolean) => void
}
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
@ -297,6 +299,7 @@ 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 setListApiConfigMeta = useCallback(
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
@ -342,6 +345,10 @@ 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)
}
// Handle marketplace data if present in state message
if (newState.marketplaceItems !== undefined) {
setMarketplaceItems(newState.marketplaceItems)
@ -592,6 +599,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setIncludeCurrentTime,
includeCurrentCost,
setIncludeCurrentCost,
showTimestamps,
setShowTimestamps,
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -68,6 +68,10 @@
"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"
}
},
"prompts": {

View file

@ -0,0 +1,75 @@
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()
})
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)
})
})
})

View file

@ -0,0 +1,38 @@
/**
* Formats a Unix timestamp (in milliseconds) to a human-readable time string.
*
* Requirements from Issue #10539:
* - 24-hour format (14:34)
* - Full date for messages from previous days (e.g., "Jan 7, 14:34")
* - Text-size same as header row text
*
* @param ts - Unix timestamp in milliseconds
* @returns Formatted time string
*/
export function formatTimestamp(ts: number): 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 hours and minutes in 24-hour format
const hours = date.getHours().toString().padStart(2, "0")
const minutes = date.getMinutes().toString().padStart(2, "0")
const time = `${hours}:${minutes}`
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}`
}