Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
1f677d5
fix(ui): save the three date and time settings all-or-nothing
KarthickMahalingam Oct 29, 2026
12ea356
test(ui): cover the message time while editing, and an invalid task t…
KarthickMahalingam Oct 29, 2026
f5193d2
fix(ui): unwind overlapping failed saves in any order, keep imports, …
KarthickMahalingam Oct 29, 2026
637e76b
fix(import): restore display settings if settings import write fails
KarthickMahalingam Oct 29, 2026
0efb6bb
fix(import): wait for every display write before restoring
KarthickMahalingam Oct 29, 2026
3a8f994
fix(import): say which display settings could not be restored
KarthickMahalingam Oct 29, 2026
3fc95ed
refactor: one write chain for saves and imports of the display settings
KarthickMahalingam Oct 29, 2026
7e2147a
fix: validate display values, order writes per key down to storage, r…
KarthickMahalingam Oct 29, 2026
c300b70
test: cover the handed-over previous value of a queued write
KarthickMahalingam Oct 29, 2026
09a22d8
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 29, 2026
7734ee7
fix: keep a store made outside a save when a failed write is rolled back
KarthickMahalingam Oct 29, 2026
ee38a57
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 30, 2026
80e48a2
fix: count only stores by others, and re-read a handed-over value if …
KarthickMahalingam Oct 30, 2026
e01f9a2
fix: a finished write is the base of a later queued one; no empty blo…
KarthickMahalingam Oct 30, 2026
c0e7c3b
test: cover a write that had started when the earlier one finished
KarthickMahalingam Oct 30, 2026
51587ed
fix(import): keep the display writes active until every step has succ…
KarthickMahalingam Oct 31, 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 @@ export const globalSettingsSchema = z.object({
* 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(),
/**
* How dates are written. "auto" follows the display language's locale.
* @default "auto"
*/
dateFormat: z.enum(["auto", "mdy", "dmy", "ymd"]).optional(),
/**
* 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
2 changes: 2 additions & 0 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { TelemetryService } from "@roo-code/telemetry"
import { logger } from "../../utils/logging"
import { supportPrompt } from "../../shared/support-prompt"
import { downgradeLegacyRooConfig } from "./routerRemoval"
import { noteSettingStored } from "./settingWrites"

type GlobalStateKey = keyof GlobalState
type SecretStateKey = keyof SecretState
Expand Down Expand Up @@ -361,6 +362,7 @@ export class ContextProxy {
}

updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
noteSettingStored(key)
if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
}
Expand Down
16 changes: 16 additions & 0 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { clearAllMocks } from "../../../test-utils/reset"
import { makeExtensionContext, makeUri } from "../../../test-utils/vscode"

import { ContextProxy } from "../ContextProxy"
import { beginSettingWrite, finishSettingWrites, performSettingWrite, undoSettingWrite } from "../settingWrites"
import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers"

vi.mock("vscode", () => ({
Expand Down Expand Up @@ -157,6 +158,21 @@ describe("ContextProxy", () => {
})

describe("updateGlobalState", () => {
it.each([
["setValue", (p: ContextProxy) => p.setValue("timeFormat", "24h")],
["setValues", (p: ContextProxy) => p.setValues({ timeFormat: "24h" })],
["updateGlobalState", async (p: ContextProxy) => p.updateGlobalState("timeFormat", "24h")],
])("reports a store of a tracked setting made by %s, so a rollback does not undo it", async (_name, store) => {
const write = beginSettingWrite("timeFormat", "auto", "24h")
await performSettingWrite(proxy, write)
await store(proxy) // someone else stores the same value after the save's write

await undoSettingWrite(proxy, write)

expect(proxy.getValue("timeFormat")).toBe("24h")
finishSettingWrites([write])
})

it("should update state directly in original context", async () => {
await proxy.updateGlobalState("apiProvider", "deepseek")

Expand Down
269 changes: 269 additions & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "../importExport"
import { ProviderSettingsManager } from "../ProviderSettingsManager"
import { ContextProxy } from "../ContextProxy"
import { beginSettingWrite, performSettingWrite, settingWriteBookkeepingSize, undoSettingWrite } from "../settingWrites"
import { CustomModesManager } from "../CustomModesManager"
import { safeWriteJson } from "../../../utils/safeWriteJson"

Expand Down Expand Up @@ -251,6 +252,274 @@ describe("importExport", () => {
})
})

describe("importSettingsFromPath display settings atomicity", () => {
type Stored = string | boolean | undefined
let stored: Map<string, Stored>
let failingKeys: Set<string>
let failingRestoreKeys: Set<string>
// Keys whose write lands only after the given delay (ms), and keys whose write stores the value and then fails.
let slowKeys: Map<string, number>
let failAfterStoringKeys: Set<string>
// Runs right after a key was stored, to emulate another save writing meanwhile.
let afterStored: Map<string, () => void>

const importWith = (globalSettings: Record<string, unknown>) => {
;(fs.readFile as Mock).mockResolvedValue(
JSON.stringify({
providerProfiles: {
currentApiConfigName: "test",
apiConfigs: {
test: { apiProvider: providerIdentifiers.openai, apiKey: "test-key", id: "test-id" },
},
},
globalSettings,
}),
)
return importSettingsFromPath("/mock/path/settings.json", {
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
})
}

beforeEach(() => {
stored = new Map<string, Stored>([
["timeFormat", "auto"],
["dateFormat", "auto"],
["showRelativeTimes", true],
["mode", "architect"],
])
failingKeys = new Set<string>()
failingRestoreKeys = new Set<string>()
slowKeys = new Map<string, number>()
failAfterStoringKeys = new Set<string>()
afterStored = new Map<string, () => void>()
mockProviderSettingsManager.export.mockResolvedValue({
currentApiConfigName: "default",
apiConfigs: { default: { apiProvider: providerIdentifiers.anthropic, id: "default-id" } },
})
mockProviderSettingsManager.import.mockResolvedValue(undefined)
mockProviderSettingsManager.listConfig.mockResolvedValue([])
mockContextProxy.setProviderSettings.mockResolvedValue(undefined)

// Map-backed double mirroring ContextProxy.setValues (Promise.all over setValue).
// The casts bridge the generic ContextProxy signatures to this untyped Map store.
const getValue = (key: string): Stored => stored.get(key)
const setValue = async (key: string, value: Stored) => {
if (failingKeys.has(key)) throw new Error(`write failed: ${key}`)
if (failingRestoreKeys.has(key) && stored.get(key) !== undefined && value === "auto") {
throw new Error(`restore failed: ${key}`)
}
const delay = slowKeys.get(key)
if (delay !== undefined) await new Promise((resolve) => setTimeout(resolve, delay))
stored.set(key, value)
afterStored.get(key)?.()
// Only the import's own write fails; putting the previous value back works.
if (failAfterStoringKeys.has(key) && value !== "auto")
throw new Error(`write failed after storing: ${key}`)
}
mockContextProxy.getValue.mockImplementation(getValue as unknown as typeof mockContextProxy.getValue)
mockContextProxy.setValue.mockImplementation(setValue as unknown as typeof mockContextProxy.setValue)
mockContextProxy.setValues.mockImplementation((async (values: Record<string, unknown>) => {
await Promise.all(Object.entries(values).map(([k, v]) => setValue(k, v as Stored)))
}) as unknown as typeof mockContextProxy.setValues)
})

const display = { timeFormat: "24h", dateFormat: "ymd", showRelativeTimes: false }

it("reports a failure that is not an Error by its text when a restore also fails", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
mockContextProxy.setValues.mockImplementationOnce(async () => {
throw "plain failure"
})
failingRestoreKeys.add("timeFormat")

const result = await importWith({ timeFormat: "24h", mode: "code" })

expect(result).toEqual({
success: false,
error: "plain failure (these settings could not be restored: timeFormat)",
})
consoleError.mockRestore()
})

it("is the latest write of the setting, so a failed save that wrote the same value does not undo it", async () => {
// A save wrote "24h" and has not finished. The import then writes the same value, and succeeds.
const saveWrite = beginSettingWrite("timeFormat", "auto", "24h")
stored.set("timeFormat", "24h")

const result = await importWith({ timeFormat: "24h" })
expect(result).toMatchObject({ success: true })

// The save now fails and is rolled back: the import's value stands.
await undoSettingWrite(mockContextProxy as unknown as ContextProxy, saveWrite)
expect(stored.get("timeFormat")).toBe("24h")
})

it("writes all three display settings on success", async () => {
const result = await importWith(display)

expect(result).toMatchObject({ success: true })
expect(stored.get("timeFormat")).toBe("24h")
expect(stored.get("dateFormat")).toBe("ymd")
expect(stored.get("showRelativeTimes")).toBe(false)
})

it("restores written display settings and reports the original error when one write fails", async () => {
failingKeys.add("dateFormat")

const result = await importWith(display)

expect(result).toEqual({ success: false, error: "write failed: dateFormat" })
expect(stored.get("timeFormat")).toBe("auto")
expect(stored.get("dateFormat")).toBe("auto")
expect(stored.get("showRelativeTimes")).toBe(true)
})

it("keeps restoring after a failed restore, and reports which settings could not be restored", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
failingKeys.add("dateFormat")
failingRestoreKeys.add("timeFormat")

const result = await importWith(display)

// The original error comes first, then the part of the import that is still stored.
expect(result).toEqual({
success: false,
error: "write failed: dateFormat (these settings could not be restored: timeFormat)",
})
expect(stored.get("timeFormat")).toBe("24h")
expect(stored.get("showRelativeTimes")).toBe(true)
expect(consoleError).toHaveBeenCalledWith(
'Failed to restore setting "timeFormat" after import failure:',
expect.any(Error),
)
consoleError.mockRestore()
})

it("does not undo a display setting changed concurrently after the import wrote it", async () => {
failingKeys.add("mode")
// Another save changes two of them right after the import wrote the last one.
afterStored.set("showRelativeTimes", () => {
stored.set("timeFormat", "12h")
stored.set("dateFormat", "dmy")
})

const result = await importWith({ ...display, mode: "code" })

expect(result).toEqual({ success: false, error: "write failed: mode" })
expect(stored.get("timeFormat")).toBe("12h")
expect(stored.get("dateFormat")).toBe("dmy")
// Matches what the import wrote, so it is rolled back.
expect(stored.get("showRelativeTimes")).toBe(true)
expect(mockContextProxy.setValue).not.toHaveBeenCalledWith("timeFormat", "auto")
expect(mockContextProxy.setValue).not.toHaveBeenCalledWith("dateFormat", "auto")
})

it("waits for every display write before restoring, so a slow write cannot land after its restore", async () => {
failingKeys.add("dateFormat")
slowKeys.set("timeFormat", 30)

const result = await importWith(display)

expect(result).toEqual({ success: false, error: "write failed: dateFormat" })
// Look again after the slow write's delay: a restore made before it landed would be overwritten by now.
await new Promise((resolve) => setTimeout(resolve, 80))
expect(stored.get("timeFormat")).toBe("auto")
expect(stored.get("showRelativeTimes")).toBe(true)
})

it("puts back a display setting whose own write stored the value and then failed", async () => {
failAfterStoringKeys.add("dateFormat")

const result = await importWith(display)

expect(result).toEqual({ success: false, error: "write failed after storing: dateFormat" })
expect(stored.get("dateFormat")).toBe("auto")
expect(stored.get("timeFormat")).toBe("auto")
})

it("reports a failure of another setting, and puts the display settings back", async () => {
failingKeys.add("mode")

const result = await importWith({ ...display, mode: "code" })

expect(result).toEqual({ success: false, error: "write failed: mode" })
expect(stored.get("timeFormat")).toBe("auto")
expect(stored.get("dateFormat")).toBe("auto")
expect(stored.get("showRelativeTimes")).toBe(true)
})

describe("when a step after the settings were written fails", () => {
it("puts the display settings back and reports the failure of that step", async () => {
mockContextProxy.setProviderSettings.mockRejectedValue(new Error("provider settings failed"))

const result = await importWith(display)

expect(result).toEqual({ success: false, error: "provider settings failed" })
expect(stored.get("timeFormat")).toBe("auto")
expect(stored.get("dateFormat")).toBe("auto")
expect(stored.get("showRelativeTimes")).toBe(true)
})

it("says which display settings could not be put back", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {})
mockContextProxy.setProviderSettings.mockRejectedValue(new Error("provider settings failed"))
failingRestoreKeys.add("timeFormat")

const result = await importWith(display)

expect(result).toEqual({
success: false,
error: "provider settings failed (these settings could not be restored: timeFormat)",
})
expect(stored.get("dateFormat")).toBe("auto")
consoleError.mockRestore()
})

it("keeps a save that wrote the same setting while the step was running", async () => {
mockContextProxy.setProviderSettings.mockImplementation(async () => {
const save = beginSettingWrite("timeFormat", stored.get("timeFormat"), "24h")
await performSettingWrite(mockContextProxy as unknown as ContextProxy, save)
throw new Error("provider settings failed")
})

await importWith(display)

expect(stored.get("timeFormat")).toBe("24h")
})

it("keeps the display settings when every step succeeds, and keeps nothing about the writes", async () => {
const result = await importWith(display)

expect(result).toMatchObject({ success: true })
expect(stored.get("timeFormat")).toBe("24h")
expect(settingWriteBookkeepingSize()).toEqual({ latestWrites: 0, queuedKeys: 0 })
})
})

it("leaves display settings absent from the file untouched on failure", async () => {
failingKeys.add("dateFormat")

await importWith({ dateFormat: "ymd" })

expect(stored.get("timeFormat")).toBe("auto")
expect(stored.get("showRelativeTimes")).toBe(true)
expect(mockContextProxy.setValue).not.toHaveBeenCalledWith("timeFormat", expect.anything())
expect(mockContextProxy.setValue).not.toHaveBeenCalledWith("showRelativeTimes", expect.anything())
})

it("does not restore non-display settings when their write fails", async () => {
failingKeys.add("mode")

const result = await importWith({ ...display, mode: "code" })

expect(result).toEqual({ success: false, error: "write failed: mode" })
expect(stored.get("mode")).toBe("architect")
expect(mockContextProxy.setValue).not.toHaveBeenCalledWith("mode", "architect")
})
})

describe("importSettings", () => {
it("should return success: false when user cancels file selection", async () => {
;(vscode.window.showOpenDialog as Mock).mockResolvedValue(undefined)
Expand Down
Loading