Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 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
642c1a6
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 31, 2026
b54a377
fix: hand a rolled-back value on only while nobody else stored the ke…
KarthickMahalingam Oct 31, 2026
c9bc5d4
fix: count only stores by others directly, marking the store being is…
KarthickMahalingam Oct 31, 2026
703d386
fix: a rejected outside store of a tracked setting is not a store to …
KarthickMahalingam Oct 31, 2026
6201ccb
fix: number the stores by others, so a rejected one cancels only itself
KarthickMahalingam Oct 31, 2026
3716fcf
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 31, 2026
06d2c77
fix: keep the stores by others only while a write of the key is pending
KarthickMahalingam Oct 31, 2026
047de2c
fix: keep recording stores by others while the restore of an undone w…
KarthickMahalingam Oct 31, 2026
7576fce
test: two restores waiting for one key
KarthickMahalingam Oct 31, 2026
dfe4c16
refactor: one helper for the count of waiting restores
KarthickMahalingam Oct 31, 2026
656361b
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 31, 2026
f35e5fe
fix: put the cache back when a store of a tracked setting is rejected
KarthickMahalingam Oct 31, 2026
9eaa8ed
Merge branch 'main' into feat/time-date-format
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
18 changes: 17 additions & 1 deletion 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 { forgetSettingStore, isTrackedSetting, noteSettingStored } from "./settingWrites"

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

updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
const storeNumber = noteSettingStored(key)

if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
}

const cachedBefore = this.stateCache[key]
this.stateCache[key] = value
return this.originalContext.globalState.update(key, value)
const updated = this.originalContext.globalState.update(key, value)
// Annotated, so it is a plain flag: the key is not narrowed to the tracked ones below.
const tracked: boolean = isTrackedSetting(key)
if (!tracked) return updated

// A rejected store never reached storage. A rollback must not treat it as someone else's store, and the
// cache must not keep a value that storage does not have, which the next save would read as what to restore.
return Promise.resolve(updated).catch((error: unknown) => {
if (storeNumber !== undefined) forgetSettingStore(key, storeNumber)
// Unless a newer store has replaced the value meanwhile: that value stays.
if (this.stateCache[key] === value) this.stateCache[key] = cachedBefore
throw error
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

private getAllGlobalState(): GlobalState {
Expand Down
73 changes: 73 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,78 @@ describe("ContextProxy", () => {
})

describe("updateGlobalState", () => {
it("does not count a store of a tracked setting that was rejected, so a rollback still happens", async () => {
await proxy.setValue("timeFormat", "auto")
const write = beginSettingWrite("timeFormat", "auto", "24h")
await performSettingWrite(proxy, write)
mockGlobalState.update.mockRejectedValueOnce(new Error("storage unavailable"))

await expect(proxy.setValue("timeFormat", "24h")).rejects.toThrow("storage unavailable")
await undoSettingWrite(proxy, write)

expect(mockGlobalState.update).toHaveBeenLastCalledWith("timeFormat", "auto")
finishSettingWrites([write])
})

describe("the cache after a rejected store of a tracked setting", () => {
it("goes back to what was stored, so the next save does not read a value storage does not have", async () => {
await proxy.setValue("timeFormat", "24h")
mockGlobalState.update.mockRejectedValueOnce(new Error("storage unavailable"))

await expect(proxy.setValue("timeFormat", "12h")).rejects.toThrow("storage unavailable")

expect(proxy.getValue("timeFormat")).toBe("24h")
const write = beginSettingWrite("timeFormat", undefined, "auto")
await performSettingWrite(proxy, write)
expect(write.previous).toBe("24h") // what is really stored, not the rejected 12h
finishSettingWrites([write])
})

it("keeps a newer store's value when the older one is rejected afterwards", async () => {
await proxy.setValue("timeFormat", "24h")
let rejectOlder!: (error: Error) => void
mockGlobalState.update.mockImplementationOnce(
() => new Promise((_resolve, reject) => (rejectOlder = reject)),
)
const older = proxy.setValue("timeFormat", "12h").catch(() => undefined) // pending
await proxy.setValue("timeFormat", "auto") // a newer store lands

rejectOlder(new Error("storage unavailable"))
await older

expect(proxy.getValue("timeFormat")).toBe("auto")
})

it("leaves an untracked setting's cache as it always was after a rejection", async () => {
mockGlobalState.update.mockRejectedValueOnce(new Error("untracked failure"))

await expect(proxy.updateGlobalState("apiProvider", "deepseek")).rejects.toThrow("untracked failure")

expect(proxy.getValue("apiProvider")).toBe("deepseek")
})
})

it("rejects with the original error, and only a tracked setting's store is wrapped", async () => {
mockGlobalState.update.mockRejectedValueOnce(new Error("untracked failure"))

await expect(proxy.updateGlobalState("apiProvider", "deepseek")).rejects.toThrow("untracked failure")
})

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
Loading
Loading