Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
738bc87
feat(ui): add time and date format settings and message timestamps
KarthickMahalingam Oct 28, 2026
c987648
fix(ui): address review feedback on time and date settings
KarthickMahalingam Oct 28, 2026
61b734e
test(visual): add Language tab baselines
KarthickMahalingam Oct 28, 2026
c8d7492
test(e2e): mask the message time in the extension host visual test
KarthickMahalingam Oct 28, 2026
d0b39ed
test(e2e): update the chat sidebar baseline for the message time
KarthickMahalingam Oct 28, 2026
daa83bd
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 29, 2026
d11277a
test(e2e): give the masked message time a fixed width so the mask box…
KarthickMahalingam Oct 29, 2026
8e1aed2
test(e2e): iterate the message times with forEach (no DOM.Iterable in…
KarthickMahalingam Oct 29, 2026
34c1d1d
test(e2e): regenerate the chat sidebar baseline from CI with a fixed-…
KarthickMahalingam Oct 29, 2026
9a3c483
feat(ui): make the time format a real radio group with roving focus a…
KarthickMahalingam Oct 29, 2026
36548f9
test(host): cover saving the date and time settings and returning the…
KarthickMahalingam Oct 29, 2026
6dd6cb9
test(ui): cover the date format on older messages across user, assist…
KarthickMahalingam Oct 29, 2026
994486a
test(e2e): pin the masked message time to one fixed-size line, and up…
KarthickMahalingam Oct 29, 2026
6aaffc2
refactor(ui): drop the unused formatDate; no tooltip that repeats an …
KarthickMahalingam Oct 29, 2026
611b7cf
fix(ui): select the time format option that receives focus; keep keyb…
KarthickMahalingam Oct 29, 2026
4f9bc9f
test: assert the webview state refresh after a save; cover the date f…
KarthickMahalingam Oct 29, 2026
6944e2d
test(ui): cover the task header time and the display language fallback
KarthickMahalingam Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 14 additions & 1 deletion apps/vscode-e2e/src/visual/electron.visual.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,21 @@ for (const scenario of scenarios) {
const tokenCountMask = webviewFrame
.frameLocator("iframe")
.locator('[data-testid="context-tokens-count"],[data-testid="context-window-size"]')
// The time under each message is the real send time, so it changes on every run, and so does the length
// of its text ("Today 9:05 AM" and "Yesterday 12:36 PM" are not equally long). A mask covers the
// element's box, so that box needs a fixed size as well: without it the masked area differs between
// runs, in width and, when the text wraps onto a second line, in height.
await contentFrame.evaluate(() => {
document.querySelectorAll<HTMLElement>('[data-testid="message-timestamp"]').forEach((time) => {
time.style.setProperty("width", "160px", "important")
time.style.setProperty("white-space", "nowrap", "important")
time.style.setProperty("overflow", "hidden", "important")
})
})
// Mask it, as the token counters above, and keep the rest of the scene byte-stable.
const messageTimeMask = webviewFrame.frameLocator("iframe").locator('[data-testid="message-timestamp"]')
await expect(sidebar).toHaveScreenshot(`electron-${scenario.name}-sidebar.png`, {
mask: [tokenCountMask],
mask: [tokenCountMask, messageTimeMask],
})

if (scenario.webviewSnapshot) {
Expand Down
15 changes: 15 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,21 @@
* When unset (or `null`), the webview inherits VS Code's `--vscode-font-size`.
*/
chatFontSize: z.number().int().min(8).max(32).nullish(),
/**
* Clock format used when rendering timestamps. "auto" follows the display language's locale.
* @default "auto"
*/
timeFormat: z.enum(["auto", "12h", "24h"]).optional(),

Check warning on line 287 in packages/types/src/global-settings.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/global-settings.ts:287: 4 mutation test gaps; example: Survived StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
/**
* How dates are written. "auto" follows the display language's locale.
* @default "auto"
*/
dateFormat: z.enum(["auto", "mdy", "dmy", "ymd"]).optional(),

Check warning on line 292 in packages/types/src/global-settings.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

packages/types/src/global-settings.ts:292: 5 mutation test gaps; example: Survived ArrayDeclaration mutant (replacement: []). See the job summary for the complete list and resolution guidance.
/**
* Show relative times ("5 min ago") in task history; when off, show the absolute date/time.
* @default true
*/
showRelativeTimes: z.boolean().optional(),
/**
* Controls the keyboard behavior for sending messages in the chat input.
* - "send": Enter sends message, Shift+Enter creates newline (default)
Expand Down
3 changes: 3 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,9 @@ export type ExtensionState = Pick<
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "chatFontSize"
| "timeFormat"
| "dateFormat"
| "showRelativeTimes"
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
Expand Down
9 changes: 9 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2860,6 +2860,9 @@
historyPreviewCollapsed,
reasoningBlockCollapsed,
chatFontSize,
timeFormat,
dateFormat,
showRelativeTimes,
enterBehavior,
cloudUserInfo,
cloudIsAuthenticated,
Expand Down Expand Up @@ -3041,6 +3044,9 @@
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
chatFontSize,
timeFormat: timeFormat ?? "auto",

Check warning on line 3047 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3047: NoCoverage StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
Comment thread
KarthickMahalingam marked this conversation as resolved.
dateFormat: dateFormat ?? "auto",

Check warning on line 3048 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3048: NoCoverage StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
showRelativeTimes: showRelativeTimes ?? true,

Check warning on line 3049 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:3049: NoCoverage BooleanLiteral mutant (replacement: false). See the job summary for the complete list and resolution guidance.
enterBehavior: enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
Expand Down Expand Up @@ -3274,6 +3280,9 @@
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
chatFontSize: stateValues.chatFontSize,
timeFormat: stateValues.timeFormat ?? "auto",
dateFormat: stateValues.dateFormat ?? "auto",
showRelativeTimes: stateValues.showRelativeTimes ?? true,
enterBehavior: stateValues.enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated,
Expand Down
75 changes: 75 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,81 @@ describe("ClineProvider", () => {
expect(state.allowedReadFiles).toEqual([])
})

describe("date and time display settings", () => {
const defaults = { timeFormat: "auto", dateFormat: "auto", showRelativeTimes: true }

/** Saves through the real `updateSettings` handler, as the settings view does. */
const save = async (updatedSettings: Record<string, unknown>) => {
await provider.resolveWebviewView(mockWebviewView)
const messageHandler = vi.mocked(mockWebviewView.webview.onDidReceiveMessage).mock.calls[0][0]
await messageHandler({ type: "updateSettings", updatedSettings })
}

test.each(Object.entries(defaults))("%s is %j when it was never saved", async (key, expected) => {
await provider.resolveWebviewView(mockWebviewView)

expect(await provider.getState()).toMatchObject({ [key]: expected })
expect(await provider.getStateToPostToWebview()).toMatchObject({ [key]: expected })
})

test.each([
["timeFormat", "12h"],
["timeFormat", "24h"],
["timeFormat", "auto"],
["dateFormat", "mdy"],
["dateFormat", "dmy"],
["dateFormat", "ymd"],
["dateFormat", "auto"],
["showRelativeTimes", true],
["showRelativeTimes", false],
])("a saved %s of %j is stored and returned to the webview", async (key, value) => {
mockPostMessage.mockClear()
await save({ [key]: value })

expect(mockContext.globalState.update).toHaveBeenCalledWith(key, value)
expect(await provider.getState()).toMatchObject({ [key]: value })
expect(await provider.getStateToPostToWebview()).toMatchObject({ [key]: value })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// The open webview is told, so the saved value does not revert on screen.
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({ type: "state", state: expect.objectContaining({ [key]: value }) }),
)
})

test("the state refresh after a save reaches the active webview with the saved time format", async () => {
mockPostMessage.mockClear()

await save({ timeFormat: "24h" })

const posted: unknown[][] = mockPostMessage.mock.calls
const states = posted
.map(([message]) => message as { type?: string; state?: { timeFormat?: string } })
.filter((message) => message.type === "state")
expect(states.length).toBeGreaterThan(0)
expect(states.at(-1)?.state?.timeFormat).toBe("24h")
})

test("an explicit false is kept, not replaced by the default of true", async () => {
await save({ showRelativeTimes: false })

expect((await provider.getState()).showRelativeTimes).toBe(false)
expect((await provider.getStateToPostToWebview()).showRelativeTimes).toBe(false)
})

test("all three are saved together and returned together", async () => {
await save({ timeFormat: "24h", dateFormat: "dmy", showRelativeTimes: false })

const expected = { timeFormat: "24h", dateFormat: "dmy", showRelativeTimes: false }
expect(await provider.getState()).toMatchObject(expected)
expect(await provider.getStateToPostToWebview()).toMatchObject(expected)
})

test("saving one setting leaves the others at their defaults", async () => {
await save({ timeFormat: "12h" })

expect(await provider.getStateToPostToWebview()).toMatchObject({ ...defaults, timeFormat: "12h" })
})
})

test("getState returns the saved allowed write files", async () => {
await provider.contextProxy.setValue("allowedWriteFiles", ["notes.md"])

Expand Down
5 changes: 5 additions & 0 deletions webview-ui/playwright/gallery/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,11 @@ export const stories: Record<string, Story> = {
</AppProviders>
)
},
"language-settings": async () => {
const { LanguageSettingsStory } =
await import("@/components/settings/__tests__/LanguageSettings.visual.fixture")
return <LanguageSettingsStory />
},
"ui-settings": async () => {
const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture")
return <UISettingsStory />
Expand Down
5 changes: 3 additions & 2 deletions webview-ui/src/__tests__/ContextWindowProgress.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { providerIdentifiers } from "@roo-code/types"

import TaskHeader from "@src/components/chat/TaskHeader"

// Mock formatLargeNumber function
vi.mock("@/utils/format", () => ({
// Keep the real format helpers (other components in the tree use them) and override only formatLargeNumber.
vi.mock("@/utils/format", async (importOriginal) => ({
...(await importOriginal<typeof import("@/utils/format")>()),
formatLargeNumber: vi.fn((num) => num.toString()),
}))

Expand Down
22 changes: 22 additions & 0 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import WarningRow from "./WarningRow"
import McpResourceRow from "../mcp/McpResourceRow"

import { Mention } from "./Mention"
import { MessageTimestamp } from "./MessageTimestamp"
import { CheckpointSaved } from "./checkpoints/CheckpointSaved"
import { FollowUpSuggest } from "./FollowUpSuggest"
import { BatchFilePermission } from "./BatchFilePermission"
Expand Down Expand Up @@ -196,6 +197,8 @@ export const ChatRowContent = ({
clineMessages,
currentTaskItem,
enableCheckpoints,
timeFormat,
dateFormat,
} = useExtensionState()
const { info: model } = useSelectedModel(apiConfiguration)
const [isEditing, setIsEditing] = useState(false)
Expand Down Expand Up @@ -1224,6 +1227,13 @@ export const ChatRowContent = ({
</div>
)}
</div>
{/* When the reply was sent. Held back while it streams, so the line does not appear and
shift as the text grows. */}
{!message.partial && (
<div className="ml-6 mt-1">
<MessageTimestamp ts={message.ts} timeFormat={timeFormat} dateFormat={dateFormat} />
</div>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
)
case "user_feedback":
Expand Down Expand Up @@ -1299,6 +1309,12 @@ export const ChatRowContent = ({
<Thumbnails images={message.images} style={{ marginTop: "8px" }} />
)}
</div>
{/* When the message was sent. Hidden while editing: the edit box replaces the bubble. */}
{!isEditing && (
<div className="ml-6 mt-1">
<MessageTimestamp ts={message.ts} timeFormat={timeFormat} dateFormat={dateFormat} />
</div>
)}
</div>
)
case "user_feedback_diff":
Expand Down Expand Up @@ -1360,6 +1376,12 @@ export const ChatRowContent = ({
<SeeNewChangesButtons key={completionCheckpoint.ts} />
) : null}
</div>
{/* When the final result was sent. Held back while it streams, like other replies. */}
{!message.partial && (
<div className="ml-6 mt-1">
<MessageTimestamp ts={message.ts} timeFormat={timeFormat} dateFormat={dateFormat} />
</div>
)}
</div>
)
case "shell_integration_warning":
Expand Down
45 changes: 45 additions & 0 deletions webview-ui/src/components/chat/MessageTimestamp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Clock } from "lucide-react"

import { cn } from "@src/lib/utils"
import { formatDateTime, formatMessageTime, type DateFormat, type TimeFormat } from "@/utils/format"

import { StandardTooltip } from "../ui/standard-tooltip"

interface MessageTimestampProps {
/** Epoch ms the message was sent. */
ts: number
/** From the user's Language settings; both default to "auto". */
timeFormat?: TimeFormat
dateFormat?: DateFormat
className?: string
}

/**
* "🕒 Yesterday 11:43 AM" under a sent message.
*
* Purely presentational: the caller supplies the user's time/date formats, so
* the component has no dependency on extension state and renders anywhere. The
* tooltip always carries the full date and time. A semantic <time> element
* gives assistive tech and copy/paste the exact value.
*/
export const MessageTimestamp = ({ ts, timeFormat, dateFormat, className }: MessageTimestampProps) => {
// Number.isFinite is not enough: a finite value beyond ±8.64e15 ms is an Invalid Date, and
// toISOString() would throw during render and take the whole chat row down with it.
const date = new Date(ts)
if (!Number.isFinite(ts) || ts <= 0 || Number.isNaN(date.getTime())) return null

return (
<StandardTooltip content={formatDateTime(ts, timeFormat, dateFormat)}>
<time
dateTime={date.toISOString()}
className={cn(
"inline-flex items-center gap-1.5 text-xs text-vscode-descriptionForeground select-none",
className,
)}
data-testid="message-timestamp">
<Clock className="size-3.5 shrink-0" aria-hidden="true" />
{formatMessageTime(ts, { timeFormat, dateFormat })}
</time>
</StandardTooltip>
)
}
8 changes: 7 additions & 1 deletion webview-ui/src/components/chat/TaskHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { vscode } from "@src/utils/vscode"
import Thumbnails from "../common/Thumbnails"
import { TaskTitleEditor } from "../history/TaskTitleEditor"
import { TaskActions } from "./TaskActions"
import { MessageTimestamp } from "./MessageTimestamp"
import { ContextWindowProgress } from "./ContextWindowProgress"
import { Mention } from "./Mention"
import { TodoListDisplay } from "./TodoListDisplay"
Expand Down Expand Up @@ -64,7 +65,7 @@ const TaskHeader = ({
todos,
}: TaskHeaderProps) => {
const { t } = useTranslation()
const { apiConfiguration, currentTaskItem } = useExtensionState()
const { apiConfiguration, currentTaskItem, timeFormat, dateFormat } = useExtensionState()
const { id: modelId, info: model } = useSelectedModel(apiConfiguration)
const [isTaskExpanded, setIsTaskExpanded] = useState(false)
const [isRenaming, setIsRenaming] = useState(false)
Expand Down Expand Up @@ -351,6 +352,11 @@ const TaskHeader = ({
</div>
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}

{/* When the task's first message was sent (follows the Time/Date format settings). */}
<div onClick={(e) => e.stopPropagation()}>
<MessageTimestamp ts={task.ts} timeFormat={timeFormat} dateFormat={dateFormat} />
</div>

<div onClick={(e) => e.stopPropagation()}>
<TaskActions
item={currentTaskItem}
Expand Down
Loading
Loading