Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 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
02c1724
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Oct 31, 2026
62e7701
fix: validate the display language on the host; fall back to English …
KarthickMahalingam Oct 31, 2026
a75db89
fix: re-stamp the earlier write only if nobody else stored the key; t…
KarthickMahalingam Nov 1, 2026
d4aaf7e
test: an empty experiments or customSupportPrompts value in a display…
KarthickMahalingam Nov 1, 2026
d95386e
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Nov 1, 2026
c1209b6
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Nov 2, 2026
424800a
refactor: drop the rollback logic for the display settings; keep host…
KarthickMahalingam Nov 2, 2026
daaa71c
fix: the public API writes check the display settings like updateSett…
KarthickMahalingam Nov 2, 2026
36b3d10
test: document the partial-failure behaviour of the display settings …
KarthickMahalingam Nov 2, 2026
fce6acd
test: fix the timestamp stories: the footer date format, and a focus …
KarthickMahalingam Nov 2, 2026
a807892
test: Linux baselines for the timestamp rows, the expanded task heade…
KarthickMahalingam Nov 2, 2026
ebc18d8
ci: rerun the visual check (the welcome contrast check sampled a fade…
KarthickMahalingam Nov 2, 2026
0802127
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Nov 3, 2026
3494afe
test: the partial-save contract of the public API path; tooltip and f…
KarthickMahalingam Nov 3, 2026
71830fa
feat: ContextProxy.setValues reads the cache back for failed keys and…
KarthickMahalingam Nov 3, 2026
649c903
fix: setValues reads a failed secret back from the secret storage too
KarthickMahalingam Nov 3, 2026
1539801
Merge branch 'main' into feat/time-date-format
KarthickMahalingam Nov 4, 2026
2df6cb6
test: cover the report wording and the pass-through key in ContextPro…
KarthickMahalingam Nov 4, 2026
ee7ac1e
fix: SettingsWriteError.cause overrides Error.cause
KarthickMahalingam Nov 4, 2026
2aabfda
fix: a failed store reads its key back into the cache for every setti…
KarthickMahalingam Nov 4, 2026
6f7c0bc
fix: the secret read-back has a deadline so the write error reaches t…
KarthickMahalingam Nov 4, 2026
b185336
test: a failed secret store that a newer write has superseded is not …
KarthickMahalingam Nov 4, 2026
763d346
fix: ContextProxy releases the write owner when a write is over and h…
KarthickMahalingam Nov 4, 2026
99a960e
fix: refreshSecrets takes over a key from a pending write, so a faile…
KarthickMahalingam Nov 4, 2026
ba55d92
fix: refreshSecrets detects a write that started and finished during …
KarthickMahalingam Nov 4, 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 @@ -89,8 +89,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 @@ -341,6 +341,9 @@ export type ExtensionState = Pick<
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "chatFontSize"
| "timeFormat"
| "dateFormat"
| "showRelativeTimes"
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
Expand Down
145 changes: 137 additions & 8 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,37 @@ type RooCodeSettingsKey = keyof RooCodeSettings

const PASS_THROUGH_STATE_KEYS = ["taskHistory"]

/** `ContextProxy.setValues` could not store every key: which ones it stored, which it did not, and the first failure. */
export class SettingsWriteError extends Error {
constructor(
readonly committed: string[],
readonly failed: string[],
override readonly cause: unknown,
) {
super(
`Could not store ${failed.join(", ")}${committed.length > 0 ? ` (stored: ${committed.join(", ")})` : ""}: ${
cause instanceof Error ? cause.message : String(cause)
}`,
)
this.name = "SettingsWriteError"
}
}

/** How long the read-back of a failed secret may take: the write error must reach the caller even if it never settles. */
const SECRET_READBACK_TIMEOUT_MS = 5_000

const withinReadBackDeadline = <T>(read: PromiseLike<T>, key: string): Promise<T> =>
new Promise<T>((resolve, reject) => {
const timer = setTimeout(
() =>
reject(new Error(`Reading back ${key} did not finish in ${SECRET_READBACK_TIMEOUT_MS / 1000} seconds`)),
SECRET_READBACK_TIMEOUT_MS,
)
Promise.resolve(read)
.then(resolve, reject)
.finally(() => clearTimeout(timer))
})

export const isPassThroughStateKey = (key: string) => PASS_THROUGH_STATE_KEYS.includes(key)

const globalSettingsExportSchema = globalSettingsSchema.omit({
Expand Down Expand Up @@ -360,13 +391,42 @@ export class ContextProxy {
return value !== undefined ? value : defaultValue
}

/**
* The write that last set each cached key, so a failed write only corrects the cache while it is still the latest:
* a newer write of the key has its own outcome, and an older failure must not replace its value.
*/
private readonly stateWriteOwner = new Map<string, object>()
private readonly secretWriteOwner = new Map<string, object>()
/** How many writes of each secret were started. Unlike the owners, it is not cleared when a write is over. */
private readonly secretWriteCount = new Map<string, number>()

updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
}

// The cache is updated first, so a read right after the call sees the value. If the store rejects, the cache is
// read back from the Memento (`globalState.get`) so that it shows what the Memento holds. This is best effort: a
// Memento can keep the attempted value in memory after a rejected update, in which case the cache and the
// Memento agree and only the durable copy, which cannot be read, may differ until the key is written again.
// The entry of the key is removed when the write is over (if it is still the latest), so the map only holds the
// writes that are in flight. An update that throws at once is turned into a rejection, so it is handled the same.
const owner = {}
this.stateWriteOwner.set(key, owner)
this.stateCache[key] = value
return this.originalContext.globalState.update(key, value)
const release = () => {
if (this.stateWriteOwner.get(key) === owner) this.stateWriteOwner.delete(key)
}
return (async () => this.originalContext.globalState.update(key, value))().then(release, (error: unknown) => {
try {
if (this.stateWriteOwner.get(key) === owner) {
this.stateCache[key] = this.originalContext.globalState.get<GlobalState[K]>(key)
}
} finally {
release()
}
throw error
})
}

private getAllGlobalState(): GlobalState {
Expand All @@ -384,12 +444,54 @@ export class ContextProxy {

storeSecret(key: SecretStateKey, value?: string) {
// Update cache.
const owner = {}
this.secretWriteOwner.set(key, owner)
this.secretWriteCount.set(key, (this.secretWriteCount.get(key) ?? 0) + 1)
this.secretCache[key] = value

// Write directly to context.
return value === undefined
? this.originalContext.secrets.delete(key)
: this.originalContext.secrets.store(key, value)
// Write directly to context. If the write rejects, the cache is read back from the secret storage while this
// write is still the latest of the key; if that fails too, the value that was never stored is dropped from the
// cache (it stays empty until the next write, `refreshSecrets` or a restart) rather than being served.
// The entry of the key is removed when the write and its read-back are over (if it is still the latest). A store
// that throws at once is turned into a rejection, so it is handled the same.
const release = () => {
if (this.secretWriteOwner.get(key) === owner) this.secretWriteOwner.delete(key)
}
const stored = (async () =>
value === undefined
? this.originalContext.secrets.delete(key)
: this.originalContext.secrets.store(key, value))()
return stored.then(release, async (error: unknown) => {
try {
if (this.secretWriteOwner.get(key) === owner) {
try {
const readBack = await withinReadBackDeadline(this.originalContext.secrets.get(key), key)
if (this.secretWriteOwner.get(key) === owner) this.secretCache[key] = readBack
} catch (readError) {
logger.error(
`Error reading back secret ${key}: ${readError instanceof Error ? readError.message : String(readError)}`,
)
if (this.secretWriteOwner.get(key) === owner) this.secretCache[key] = undefined
}
}
} finally {
release()
}
throw error
})
}

/**
* Loads one secret into the cache. A write of the key that started while the secret was being read has its own
* outcome, even if it is already over, so the (older) value that was read is dropped. Otherwise the load takes over the key from a write that is
* still pending, so that a failure of that write cannot replace what was just loaded.
*/
private async refreshSecret(key: SecretStateKey) {
const writes = this.secretWriteCount.get(key)
const value = await this.originalContext.secrets.get(key)
if (this.secretWriteCount.get(key) !== writes) return
this.secretWriteOwner.delete(key)
this.secretCache[key] = value
}

/**
Expand All @@ -400,7 +502,7 @@ export class ContextProxy {
const promises = [
...SECRET_STATE_KEYS.map(async (key) => {
try {
this.secretCache[key] = await this.originalContext.secrets.get(key)
await this.refreshSecret(key)
} catch (error) {
logger.error(
`Error refreshing secret ${key}: ${error instanceof Error ? error.message : String(error)}`,
Expand All @@ -409,7 +511,7 @@ export class ContextProxy {
}),
...GLOBAL_SECRET_KEYS.map(async (key) => {
try {
this.secretCache[key] = await this.originalContext.secrets.get(key)
await this.refreshSecret(key)
} catch (error) {
logger.error(
`Error refreshing global secret ${key}: ${error instanceof Error ? error.message : String(error)}`,
Expand Down Expand Up @@ -555,9 +657,36 @@ export class ContextProxy {
return { ...globalState, ...secretState }
}

/**
* Writes every value at once. This is not a transaction, for any setting: each key is stored on its own, and the
* stores that completed are not undone. If any store rejects, every store is still awaited (the call stays pending
* until the last one has settled) and then rejects with a `SettingsWriteError` that says which keys were stored and
* which were not. A failed store has already read its key back into the cache (see `updateGlobalState` and
* `storeSecret`; best effort for global state, whose Memento may keep the attempted value). Callers that need more than this, such as a
* rollback, must provide it themselves.
*/
public async setValues(values: RooCodeSettings) {
const entries = Object.entries(values) as [RooCodeSettingsKey, unknown][]
await Promise.all(entries.map(([key, value]) => this.setValue(key, value)))
const results = await Promise.allSettled(entries.map(([key, value]) => this.setValue(key, value)))

const committed: string[] = []
const failed: string[] = []
let cause: unknown
let hasCause = false
for (const [index, [key]] of entries.entries()) {
const result = results[index]
if (result.status === "fulfilled") {
committed.push(key)
continue
}
// The first failure is the cause, even when it was rejected without a reason.
if (!hasCause) {
cause = result.reason
hasCause = true
}
failed.push(key)
}
if (failed.length > 0) throw new SettingsWriteError(committed, failed, cause)
}

/**
Expand Down
Loading
Loading