diff --git a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png index 3791a71175..dfc6fee680 100644 Binary files a/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png and b/apps/vscode-e2e/src/visual/__screenshots__/electron-chat-dark-sidebar.png differ diff --git a/apps/vscode-e2e/src/visual/electron.visual.ts b/apps/vscode-e2e/src/visual/electron.visual.ts index 155f496980..c10ee7cb0c 100644 --- a/apps/vscode-e2e/src/visual/electron.visual.ts +++ b/apps/vscode-e2e/src/visual/electron.visual.ts @@ -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('[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) { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index eb51c1d67d..f5757a51c7 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index dc407325d9..0bf3348d8d 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -339,6 +339,9 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" | "chatFontSize" + | "timeFormat" + | "dateFormat" + | "showRelativeTimes" | "enterBehavior" | "includeCurrentTime" | "includeCurrentCost" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 32b82feaaa..f40315bd30 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2860,6 +2860,9 @@ export class ClineProvider historyPreviewCollapsed, reasoningBlockCollapsed, chatFontSize, + timeFormat, + dateFormat, + showRelativeTimes, enterBehavior, cloudUserInfo, cloudIsAuthenticated, @@ -3041,6 +3044,9 @@ export class ClineProvider historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, chatFontSize, + timeFormat: timeFormat ?? "auto", + dateFormat: dateFormat ?? "auto", + showRelativeTimes: showRelativeTimes ?? true, enterBehavior: enterBehavior ?? "send", cloudUserInfo, cloudIsAuthenticated: cloudIsAuthenticated ?? false, @@ -3274,6 +3280,9 @@ export class ClineProvider 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, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 779313d385..668c3bc36c 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -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) => { + 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 }) + // 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"]) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..632c376836 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1853,6 +1853,340 @@ describe("webviewMessageHandler - message dialog preferences", () => { }) }) +describe("webviewMessageHandler - the display settings are saved all-or-nothing", () => { + const stored: Record = {} + + type WriteHook = (key: string, value: unknown) => Promise | void + + /** + * Makes the write of one key fail. `beforeThrow` can hold the failure back (to let another save run) or + * change what is stored, as a store whose cache was already updated when the write failed. + */ + const failWriteOf = (key: string, value: unknown, beforeThrow?: WriteHook) => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (k: string, v: unknown) => { + if (k === key && v === value) { + await beforeThrow?.(k, v) + throw new Error("storage unavailable") + } + return real(k as never, v as never) + }) + } + + const save = (updatedSettings: Record) => + webviewMessageHandler(mockClineProvider, { type: "updateSettings", updatedSettings }) + + beforeEach(() => { + vi.clearAllMocks() + for (const key of Object.keys(stored)) delete stored[key] + Object.assign(stored, { timeFormat: "auto", dateFormat: "auto", soundEnabled: false }) + vi.mocked(mockClineProvider.contextProxy.getValue).mockImplementation((key: string) => stored[key] as never) + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + stored[key] = value + }) + vi.mocked(mockClineProvider.postStateToWebview).mockResolvedValue(undefined as never) + }) + + it("stores every setting and refreshes the webview", async () => { + await save({ timeFormat: "24h", dateFormat: "ymd", showRelativeTimes: false }) + + expect(stored).toMatchObject({ timeFormat: "24h", dateFormat: "ymd", showRelativeTimes: false }) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("puts back the values already stored when a later one fails, and reports it", async () => { + failWriteOf("showRelativeTimes", false) + + await save({ timeFormat: "24h", dateFormat: "ymd", showRelativeTimes: false }) + + expect(stored).toMatchObject({ timeFormat: "auto", dateFormat: "auto" }) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Save failed")) + // The webview is re-synced with what is really stored: after the values were put back, not before. + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + const lastWrite = Math.max(...vi.mocked(mockClineProvider.contextProxy.setValue).mock.invocationCallOrder) + expect(vi.mocked(mockClineProvider.postStateToWebview).mock.invocationCallOrder[0]).toBeGreaterThan(lastWrite) + }) + + it("puts back the value whose own write failed after it had changed the stored value", async () => { + // As a store whose cache is updated before its write to disk fails. + failWriteOf("dateFormat", "dmy", (key, value) => { + stored[key] = value + }) + + await save({ dateFormat: "dmy" }) + + expect(stored.dateFormat).toBe("auto") + }) + + it("restores in reverse order", async () => { + const order: string[] = [] + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + order.push(key) + if (key === "showRelativeTimes" && value === false) throw new Error("boom") + return real(key as never, value as never) + }) + + await save({ timeFormat: "24h", dateFormat: "ymd", showRelativeTimes: false }) + + // Three writes forward (the last one fails and never changed the stored value, so there is nothing to put + // back for it), then the restores in the opposite order. + expect(order).toEqual(["timeFormat", "dateFormat", "showRelativeTimes", "dateFormat", "timeFormat"]) + }) + + it("clears a value again that had no stored value before the save", async () => { + failWriteOf("dateFormat", "ymd") + + await save({ showRelativeTimes: false, dateFormat: "ymd" }) + + expect(stored.showRelativeTimes).toBeUndefined() + }) + + describe("two saves at once", () => { + it("a failed save does not undo another save that wrote the same value", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("dateFormat", "ymd", () => held) + + // Save A stores timeFormat 24h, then waits at its failing write. + const a = save({ timeFormat: "24h", dateFormat: "ymd" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("24h")) + + // Save B stores the same value for the same key, and finishes. + await save({ timeFormat: "24h" }) + + // Save A now fails and rolls back. B's write of the same value must stand. + releaseFailure() + await a + + expect(stored.timeFormat).toBe("24h") + expect(vscode.window.showErrorMessage).toHaveBeenCalledTimes(1) + }) + + it("a failed save does not undo another save's different value", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("dateFormat", "ymd", () => held) + + const a = save({ timeFormat: "24h", dateFormat: "ymd" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("24h")) + await save({ timeFormat: "12h" }) + + releaseFailure() + await a + + expect(stored.timeFormat).toBe("12h") + }) + + /** Two saves that both write the same key and then fail, each held at its own failing write. */ + const twoFailingSaves = async (releaseFirst: "a" | "b") => { + const gates: Record<"a" | "b", () => void> = { a: () => {}, b: () => {} } + const heldA = new Promise((resolve) => (gates.a = resolve)) + const heldB = new Promise((resolve) => (gates.b = resolve)) + failWriteOf("dateFormat", "ymd", () => heldA) + failWriteOf("showRelativeTimes", false, () => heldB) + + const a = save({ timeFormat: "12h", dateFormat: "ymd" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("12h")) + const b = save({ timeFormat: "24h", showRelativeTimes: false }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("24h")) + + const [first, second] = releaseFirst === "a" ? (["a", "b"] as const) : (["b", "a"] as const) + gates[first]() + await (first === "a" ? a : b) + const afterFirst = stored.timeFormat + gates[second]() + await (second === "a" ? a : b) + return { afterFirst, final: stored.timeFormat } + } + + it("puts back the value from before both saves when both fail, the earlier one failing first", async () => { + const { afterFirst, final } = await twoFailingSaves("a") + + // The later save is still pending with 24h, so the first failure leaves it. Then it restores "auto". + expect(afterFirst).toBe("24h") + expect(final).toBe("auto") + }) + + it("puts back the value from before both saves when both fail, the later one failing first", async () => { + const { afterFirst, final } = await twoFailingSaves("b") + + // The later save restores the earlier one's pending value, and the earlier one then restores "auto". + expect(afterFirst).toBe("12h") + expect(final).toBe("auto") + }) + + it("puts back the original value when three overlapping saves all fail, the middle one first", async () => { + const release: Record<"a" | "b" | "c", () => void> = { a: () => {}, b: () => {}, c: () => {} } + const gate = (name: "a" | "b" | "c") => new Promise((resolve) => (release[name] = resolve)) + const gates = { a: gate("a"), b: gate("b"), c: gate("c") } + failWriteOf("dateFormat", "ymd", () => gates.a) + failWriteOf("dateFormat", "dmy", () => gates.b) + failWriteOf("showRelativeTimes", false, () => gates.c) + + const a = save({ timeFormat: "12h", dateFormat: "ymd" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("12h")) + const b = save({ timeFormat: "24h", dateFormat: "dmy" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("24h")) + const c = save({ timeFormat: "auto", showRelativeTimes: false }) + await vi.waitFor(() => + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("timeFormat", "auto"), + ) + + release.b() + await b + expect(stored.timeFormat).toBe("auto") // the newest is still pending + + release.c() + await c + expect(stored.timeFormat).toBe("12h") // the oldest save's pending value, not the middle one's + + release.a() + await a + expect(stored.timeFormat).toBe("auto") // from before all three + }) + + it("keeps the earlier save's value when the later one fails and the earlier one succeeds", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("showRelativeTimes", false, () => held) + + const later = (async () => { + await vi.waitFor(() => expect(stored.timeFormat).toBe("12h")) + return save({ timeFormat: "24h", showRelativeTimes: false }) + })() + await save({ timeFormat: "12h" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("24h")) + + releaseFailure() + await later + + expect(stored.timeFormat).toBe("12h") + }) + + it("keeps a value changed outside a save, such as an import, when a failed save rolls back", async () => { + failWriteOf("dateFormat", "ymd", () => { + stored.timeFormat = "12h" // an import writes the same key meanwhile + }) + + await save({ timeFormat: "24h", dateFormat: "ymd" }) + + expect(stored.timeFormat).toBe("12h") + }) + + it("the failed save still puts back the keys the other save did not touch", async () => { + let releaseFailure!: () => void + const held = new Promise((resolve) => (releaseFailure = resolve)) + failWriteOf("dateFormat", "ymd", () => held) + + const a = save({ timeFormat: "24h", dateFormat: "ymd" }) + await vi.waitFor(() => expect(stored.timeFormat).toBe("24h")) + await save({ showRelativeTimes: false }) + + releaseFailure() + await a + + expect(stored.timeFormat).toBe("auto") // A's own write is put back + expect(stored.showRelativeTimes).toBe(false) // B's is untouched + }) + }) + + it("keeps going when a restore itself fails, and says which one", async () => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + if (key === "dateFormat" && value === "ymd") throw new Error("dateFormat is broken") + if (key === "timeFormat" && value === "auto") throw new Error("cannot restore") + return real(key as never, value as never) + }) + + await save({ timeFormat: "24h", dateFormat: "ymd" }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("Could not restore timeFormat")) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + it("reports a restore failure and a save failure that are not Errors", async () => { + const real = vi.mocked(mockClineProvider.contextProxy.setValue).getMockImplementation()! + vi.mocked(mockClineProvider.contextProxy.setValue).mockImplementation(async (key: string, value: unknown) => { + if (key === "dateFormat" && value === "ymd") throw "plain save failure" + if (key === "timeFormat" && value === "auto") throw "plain restore failure" + return real(key as never, value as never) + }) + + await save({ timeFormat: "24h", dateFormat: "ymd" }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("plain restore failure")) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("plain save failure")) + }) + + it("leaves every other setting as it was written: it is not put back when a later write fails", async () => { + failWriteOf("dateFormat", "ymd") + + await save({ soundEnabled: true, dateFormat: "ymd" }) + + expect(stored.soundEnabled).toBe(true) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + describe("a failure while writing another setting", () => { + it("puts the display settings back, refreshes the webview, and then follows the existing error path", async () => { + failWriteOf("soundEnabled", true) + + await expect(save({ timeFormat: "24h", soundEnabled: true })).rejects.toThrow("storage unavailable") + + expect(stored.timeFormat).toBe("auto") + expect(mockClineProvider.postStateToWebview).toHaveBeenCalled() + // No toast: the existing path did not show one for these settings. + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("does not reject for a failure of a display setting, which is reported to the user instead", async () => { + failWriteOf("timeFormat", "24h") + + await expect(save({ timeFormat: "24h" })).resolves.toBeUndefined() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + }) + }) + + it("does nothing when there are no settings to save", async () => { + await webviewMessageHandler(mockClineProvider, { type: "updateSettings" }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + }) + + describe("when the state refresh fails", () => { + it("keeps a stored save and does not report a failure", async () => { + vi.mocked(mockClineProvider.postStateToWebview).mockRejectedValueOnce(new Error("state unavailable")) + + await save({ timeFormat: "24h", dateFormat: "ymd" }) + + expect(stored).toMatchObject({ timeFormat: "24h", dateFormat: "ymd" }) + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("state unavailable")) + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + + it("logs a refresh failure that is not an Error", async () => { + vi.mocked(mockClineProvider.postStateToWebview).mockRejectedValueOnce("plain refresh failure") + + await save({ timeFormat: "24h" }) + + expect(mockClineProvider.log).toHaveBeenCalledWith(expect.stringContaining("plain refresh failure")) + }) + + it("still reports a failed save", async () => { + failWriteOf("timeFormat", "24h") + vi.mocked(mockClineProvider.postStateToWebview).mockRejectedValueOnce(new Error("state unavailable")) + + await save({ timeFormat: "24h" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("common:errors.settings_save_failed") + }) + }) +}) + describe("webviewMessageHandler - mcpEnabled", () => { let mockMcpHub: any diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index bd7c589958..670ae0ecc6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -130,6 +130,60 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +/** + * The settings a save puts back if it fails: the three display settings of the Language tab, which are saved + * together and must not be left half changed. Every other setting keeps the way it is written, and the way a + * failure is handled, as before. + */ +const ROLLBACK_SETTING_KEYS = new Set(["timeFormat", "dateFormat", "showRelativeTimes"]) + +/** One write of a rolled-back setting by a save, linked to the writes of other saves that overlap it. */ +interface SettingWrite { + key: string + /** What was stored when this save wrote it. With overlapping saves, this can be another save's pending value. */ + previous: unknown + wrote: unknown + /** The write before this one that has not finished yet, and the one after it. */ + prev?: SettingWrite + next?: SettingWrite + /** The save has finished: it succeeded, or it has been rolled back. */ + done: boolean +} + +/** + * The latest write of each of those settings. The store is shared by every provider (sidebar, editor tab), so saves + * can overlap, and writes to the same key form a chain. Rolling back a write puts the key back only while it is the + * latest one and the stored value is still what it wrote, so a newer save's value, or a change made outside a save + * (an import), is kept. A write that is rolled back while a newer one is pending is taken out of the chain, so that + * if the newer one fails too, it restores what was stored before either of them. + */ +const lastWriteOfSetting = new Map() + +const undoSettingWrite = async (provider: ClineProvider, write: SettingWrite) => { + const { key } = write + write.done = true + + if (lastWriteOfSetting.get(key) !== write) { + // A newer write exists. Make it restore the value from before this one if it fails as well. + const { next } = write + if (next && !next.done) { + next.previous = write.previous + next.prev = write.prev + if (write.prev) write.prev.next = next + } + + return + } + + // The write before this one is pending again: it is the latest, and the value now stored is its value. + if (write.prev && !write.prev.done) lastWriteOfSetting.set(key, write.prev) + else lastWriteOfSetting.delete(key) + + if (provider.contextProxy.getValue(key as keyof RooCodeSettings) === write.wrote) { + await provider.contextProxy.setValue(key as keyof RooCodeSettings, write.previous as never) + } +} + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -737,8 +791,19 @@ export const webviewMessageHandler = async ( } break - case "updateSettings": - if (message.updatedSettings) { + case "updateSettings": { + // The display settings are saved all-or-nothing: if a write fails, the ones this save already stored are + // put back. A key is recorded before it is written, so a write that failed after changing the stored + // value is put back too (see `undoSettingWrite` for when a key is left alone). A failure while writing + // any other setting is not handled here: it follows the existing error path, after the display + // settings have been put back. + const written: SettingWrite[] = [] + let failedKey: string | undefined + let unhandledFailure: unknown + + try { + if (!message.updatedSettings) break + if (message.updatedSettings.destructiveCommandGuardEnabled === true) { try { const { ensureDcgInstalled } = await import("../../services/destructive-command-guard") @@ -758,6 +823,8 @@ export const webviewMessageHandler = async ( } for (const [key, value] of Object.entries(message.updatedSettings)) { + failedKey = key + const previous = provider.contextProxy.getValue(key as keyof RooCodeSettings) let newValue = value if (key === "language") { @@ -867,13 +934,60 @@ export const webviewMessageHandler = async ( } } + if (ROLLBACK_SETTING_KEYS.has(key)) { + const before = lastWriteOfSetting.get(key) + const write: SettingWrite = { + key, + previous, + wrote: newValue, + prev: before && !before.done ? before : undefined, + done: false, + } + if (write.prev) write.prev.next = write + written.push(write) + lastWriteOfSetting.set(key, write) + } + await provider.contextProxy.setValue(key as keyof RooCodeSettings, newValue) } + for (const write of written) write.done = true + } catch (error) { + const failure = error instanceof Error ? error.message : String(error) + + for (const write of written.reverse()) { + try { + await undoSettingWrite(provider, write) + } catch (rollbackError) { + provider.log( + `[updateSettings] Could not restore ${write.key}: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`, + ) + } + } + + provider.log(`[updateSettings] Save failed: ${failure}`) + + if (failedKey !== undefined && ROLLBACK_SETTING_KEYS.has(failedKey)) { + vscode.window.showErrorMessage(t("common:errors.settings_save_failed", { error: failure })) + } else { + // Not a display setting: handled as it always was, once the webview has been refreshed. + unhandledFailure = error + } + } + + // The outcome is decided above. A failed refresh must not undo a stored save. + try { await provider.postStateToWebview() + } catch (error) { + provider.log( + `[updateSettings] Could not refresh the webview state: ${error instanceof Error ? error.message : String(error)}`, + ) } + if (unhandledFailure !== undefined) throw unhandledFailure + break + } case "terminalOperation": if (message.terminalOperation) { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 579cd6b7d7..16561c5bf7 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -62,6 +62,7 @@ "cannot_access_path": "No es pot accedir a la ruta {{path}}: {{error}}", "path_outside_workspace": "La ruta és fora de l'espai de treball", "settings_import_failed": "Ha fallat la importació de la configuració: {{error}}.", + "settings_save_failed": "No s'ha pogut desar la configuració. Alguns valors poden haver quedat parcialment canviats: {{error}}", "mistake_limit_guidance": "Això pot indicar un error en el procés de pensament del model o la incapacitat d'utilitzar una eina correctament, que es pot mitigar amb orientació de l'usuari (p. ex. \"Prova de dividir la tasca en passos més petits\").", "violated_organization_allowlist": "Ha fallat l'execució de la tasca: el perfil actual no és compatible amb la configuració de la teva organització", "condense_failed": "Ha fallat la condensació del context", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 3d8175a77e..4eac519f9b 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Zugriff auf Pfad {{path}} nicht möglich: {{error}}", "path_outside_workspace": "Pfad liegt außerhalb des Arbeitsbereichs", "settings_import_failed": "Fehler beim Importieren der Einstellungen: {{error}}.", + "settings_save_failed": "Die Einstellungen konnten nicht gespeichert werden. Einige könnten teilweise geändert geblieben sein: {{error}}", "mistake_limit_guidance": "Dies kann auf einen Fehler im Denkprozess des Modells oder die Unfähigkeit hinweisen, ein Tool richtig zu verwenden, was durch Benutzerführung behoben werden kann (z.B. \"Versuche, die Aufgabe in kleinere Schritte zu unterteilen\").", "violated_organization_allowlist": "Aufgabe konnte nicht ausgeführt werden: Das aktuelle Profil ist nicht kompatibel mit den Einstellungen deiner Organisation", "condense_failed": "Fehler beim Verdichten des Kontexts", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index a7d616028f..ea4ca74c7d 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Cannot access path {{path}}: {{error}}", "path_outside_workspace": "Path is outside the workspace", "settings_import_failed": "Settings import failed: {{error}}.", + "settings_save_failed": "Settings could not be saved. Some may be left partly changed: {{error}}", "mistake_limit_guidance": "This may indicate a failure in the model's thought process or inability to use a tool properly, which can be mitigated with some user guidance (e.g. \"Try breaking down the task into smaller steps\").", "violated_organization_allowlist": "Failed to run task: the current profile isn't compatible with your organization settings", "condense_failed": "Failed to condense context", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 2eb6848f11..4f4b6fd4c6 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "No se puede acceder a la ruta {{path}}: {{error}}", "path_outside_workspace": "La ruta está fuera del espacio de trabajo", "settings_import_failed": "Error al importar la configuración: {{error}}.", + "settings_save_failed": "No se pudieron guardar los ajustes. Algunos pueden haber quedado parcialmente modificados: {{error}}", "mistake_limit_guidance": "Esto puede indicar un fallo en el proceso de pensamiento del modelo o la incapacidad de usar una herramienta correctamente, lo cual puede mitigarse con orientación del usuario (ej. \"Intenta dividir la tarea en pasos más pequeños\").", "violated_organization_allowlist": "Error al ejecutar la tarea: el perfil actual no es compatible con la configuración de tu organización", "condense_failed": "Error al condensar el contexto", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index b6b65a88de..dcdb3c1dd3 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Impossible d'accéder au chemin {{path}} : {{error}}", "path_outside_workspace": "Le chemin est à l'extérieur de l'espace de travail", "settings_import_failed": "Échec de l'importation des paramètres : {{error}}", + "settings_save_failed": "Impossible d'enregistrer les paramètres. Certains peuvent être restés partiellement modifiés : {{error}}", "mistake_limit_guidance": "Cela peut indiquer un échec dans le processus de réflexion du modèle ou une incapacité à utiliser un outil correctement, ce qui peut être atténué avec des conseils de l'utilisateur (par ex. \"Essaie de diviser la tâche en étapes plus petites\").", "violated_organization_allowlist": "Échec de l'exécution de la tâche : le profil actuel n'est pas compatible avec les paramètres de votre organisation", "condense_failed": "Échec de la condensation du contexte", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index f69b5adccf..d4572871f7 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "पाथ {{path}} तक पहुंच नहीं पा रहे हैं: {{error}}", "path_outside_workspace": "पथ वर्कस्पेस से बाहर है", "settings_import_failed": "सेटिंग्स इम्पोर्ट करने में विफल: {{error}}।", + "settings_save_failed": "सेटिंग्स सहेजी नहीं जा सकीं। कुछ सेटिंग्स आंशिक रूप से बदली रह सकती हैं: {{error}}", "mistake_limit_guidance": "यह मॉडल की सोच प्रक्रिया में विफलता या किसी टूल का सही उपयोग न कर पाने का संकेत हो सकता है, जिसे उपयोगकर्ता के मार्गदर्शन से ठीक किया जा सकता है (जैसे \"कार्य को छोटे चरणों में बांटने की कोशिश करें\")।", "violated_organization_allowlist": "कार्य चलाने में विफल: वर्तमान प्रोफ़ाइल आपके संगठन की सेटिंग्स के साथ संगत नहीं है", "condense_failed": "संदर्भ को संक्षिप्त करने में विफल", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index e401612bf3..85abdfaa8b 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Tidak dapat mengakses path {{path}}: {{error}}", "path_outside_workspace": "Path berada di luar workspace", "settings_import_failed": "Impor pengaturan gagal: {{error}}.", + "settings_save_failed": "Pengaturan tidak dapat disimpan. Sebagian mungkin tetap berubah sebagian: {{error}}", "mistake_limit_guidance": "Ini mungkin menunjukkan kegagalan dalam proses pemikiran model atau ketidakmampuan untuk menggunakan tool dengan benar, yang dapat diatasi dengan beberapa panduan pengguna (misalnya \"Coba bagi tugas menjadi langkah-langkah yang lebih kecil\").", "violated_organization_allowlist": "Gagal menjalankan tugas: profil saat ini tidak kompatibel dengan pengaturan organisasi kamu", "condense_failed": "Gagal mengompres konteks", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index aeb984940a..2f570174c6 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Impossibile accedere al percorso {{path}}: {{error}}", "path_outside_workspace": "Il percorso è fuori dall'area di lavoro", "settings_import_failed": "Importazione delle impostazioni fallita: {{error}}.", + "settings_save_failed": "Impossibile salvare le impostazioni. Alcune potrebbero essere rimaste modificate in parte: {{error}}", "mistake_limit_guidance": "Questo può indicare un fallimento nel processo di pensiero del modello o l'incapacità di utilizzare correttamente uno strumento, che può essere mitigato con la guida dell'utente (ad es. \"Prova a suddividere l'attività in passaggi più piccoli\").", "violated_organization_allowlist": "Impossibile eseguire l'attività: il profilo corrente non è compatibile con le impostazioni della tua organizzazione", "condense_failed": "Impossibile condensare il contesto", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a7ec0c4de3..2fb1c19478 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "パス {{path}} にアクセスできません:{{error}}", "path_outside_workspace": "パスはワークスペースの外にあります", "settings_import_failed": "設定のインポートに失敗しました:{{error}}", + "settings_save_failed": "設定を保存できませんでした。一部の設定が部分的に変更されたままになっている可能性があります:{{error}}", "mistake_limit_guidance": "これは、モデルの思考プロセスの失敗やツールを適切に使用できないことを示している可能性があり、ユーザーのガイダンスによって軽減できます(例:「タスクをより小さなステップに分割してみてください」)。", "violated_organization_allowlist": "タスクの実行に失敗しました: 現在のプロファイルは組織の設定と互換性がありません", "condense_failed": "コンテキストの圧縮に失敗しました", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 906652ac14..32f003633a 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "경로 {{path}}에 접근할 수 없습니다: {{error}}", "path_outside_workspace": "경로가 작업 영역 밖에 있습니다", "settings_import_failed": "설정 가져오기 실패: {{error}}.", + "settings_save_failed": "설정을 저장할 수 없습니다. 일부 설정이 부분적으로 변경된 상태로 남아 있을 수 있습니다: {{error}}", "mistake_limit_guidance": "이는 모델의 사고 과정 실패나 도구를 제대로 사용하지 못하는 것을 나타낼 수 있으며, 사용자 가이드를 통해 완화할 수 있습니다 (예: \"작업을 더 작은 단계로 나누어 시도해보세요\").", "violated_organization_allowlist": "작업 실행 실패: 현재 프로필이 조직 설정과 호환되지 않습니다", "condense_failed": "컨텍스트 압축에 실패했습니다", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 34cdbbbe89..823b12b657 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Kan pad {{path}} niet openen: {{error}}", "path_outside_workspace": "Pad is buiten de werkomgeving", "settings_import_failed": "Importeren van instellingen mislukt: {{error}}.", + "settings_save_failed": "De instellingen konden niet worden opgeslagen. Sommige zijn mogelijk gedeeltelijk gewijzigd gebleven: {{error}}", "mistake_limit_guidance": "Dit kan duiden op een fout in het denkproces van het model of het onvermogen om een tool correct te gebruiken, wat kan worden verminderd met gebruikersbegeleiding (bijv. \"Probeer de taak op te delen in kleinere stappen\").", "violated_organization_allowlist": "Taak uitvoeren mislukt: het huidige profiel is niet compatibel met de instellingen van uw organisatie", "condense_failed": "Comprimeren van context mislukt", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 2a743fc009..9f4b955cab 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Nie można uzyskać dostępu do ścieżki {{path}}: {{error}}", "path_outside_workspace": "Ścieżka znajduje się poza obszarem roboczym", "settings_import_failed": "Nie udało się zaimportować ustawień: {{error}}.", + "settings_save_failed": "Nie udało się zapisać ustawień. Niektóre mogły pozostać częściowo zmienione: {{error}}", "mistake_limit_guidance": "To może wskazywać na błąd w procesie myślowym modelu lub niezdolność do prawidłowego użycia narzędzia, co można złagodzić poprzez wskazówki użytkownika (np. \"Spróbuj podzielić zadanie na mniejsze kroki\").", "violated_organization_allowlist": "Nie udało się uruchomić zadania: bieżący profil nie jest kompatybilny z ustawieniami Twojej organizacji", "condense_failed": "Nie udało się skondensować kontekstu", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index eaf1cae06b..451dfa6cfe 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -62,6 +62,7 @@ "cannot_access_path": "Não é possível acessar o caminho {{path}}: {{error}}", "path_outside_workspace": "O caminho está fora do espaço de trabalho", "settings_import_failed": "Falha ao importar configurações: {{error}}", + "settings_save_failed": "Não foi possível salvar as configurações. Algumas podem ter ficado parcialmente alteradas: {{error}}", "mistake_limit_guidance": "Isso pode indicar uma falha no processo de pensamento do modelo ou incapacidade de usar uma ferramenta adequadamente, o que pode ser mitigado com orientação do usuário (ex. \"Tente dividir a tarefa em etapas menores\").", "violated_organization_allowlist": "Falha ao executar a tarefa: o perfil atual não é compatível com as configurações da sua organização", "condense_failed": "Falha ao condensar o contexto", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6f48365ec1..f26457612a 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Невозможно получить доступ к пути {{path}}: {{error}}", "path_outside_workspace": "Путь находится вне рабочего пространства", "settings_import_failed": "Не удалось импортировать настройки: {{error}}.", + "settings_save_failed": "Не удалось сохранить настройки. Некоторые могли остаться частично изменёнными: {{error}}", "mistake_limit_guidance": "Это может указывать на сбой в процессе мышления модели или неспособность правильно использовать инструмент, что можно смягчить с помощью руководства пользователя (например, \"Попробуйте разбить задачу на более мелкие шаги\").", "violated_organization_allowlist": "Не удалось выполнить задачу: текущий профиль несовместим с настройками вашей организации", "condense_failed": "Не удалось сжать контекст", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index b5c543c405..83d54b62a0 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "{{path}} yoluna erişilemiyor: {{error}}", "path_outside_workspace": "Yol çalışma alanının dışında", "settings_import_failed": "Ayarlar içe aktarılamadı: {{error}}.", + "settings_save_failed": "Ayarlar kaydedilemedi. Bazıları kısmen değişmiş olarak kalmış olabilir: {{error}}", "mistake_limit_guidance": "Bu, modelin düşünce sürecindeki bir başarısızlığı veya bir aracı düzgün kullanamama durumunu gösterebilir, bu da kullanıcı rehberliği ile hafifletilebilir (örn. \"Görevi daha küçük adımlara bölmeyi deneyin\").", "violated_organization_allowlist": "Görev yürütülemedi: Geçerli profil kuruluşunuzun ayarlarıyla uyumlu değil", "condense_failed": "Bağlam sıkıştırılamadı", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index cb2ed8e9b8..f1bce7743f 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "Không thể truy cập đường dẫn {{path}}: {{error}}", "path_outside_workspace": "Đường dẫn nằm ngoài không gian làm việc", "settings_import_failed": "Nhập cài đặt thất bại: {{error}}.", + "settings_save_failed": "Không thể lưu cài đặt. Một số cài đặt có thể vẫn bị thay đổi một phần: {{error}}", "mistake_limit_guidance": "Điều này có thể cho thấy sự thất bại trong quá trình suy nghĩ của mô hình hoặc không thể sử dụng công cụ đúng cách, có thể được giảm thiểu bằng hướng dẫn của người dùng (ví dụ: \"Hãy thử chia nhỏ nhiệm vụ thành các bước nhỏ hơn\").", "violated_organization_allowlist": "Không thể chạy tác vụ: hồ sơ hiện tại không tương thích với cài đặt của tổ chức của bạn", "condense_failed": "Không thể nén ngữ cảnh", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 08cfe736b5..1ddac8f209 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -63,6 +63,7 @@ "cannot_access_path": "无法访问路径 {{path}}:{{error}}", "path_outside_workspace": "路径位于工作区之外", "settings_import_failed": "设置导入失败:{{error}}。", + "settings_save_failed": "无法保存设置。部分设置可能仍处于部分更改的状态:{{error}}", "mistake_limit_guidance": "这可能表明模型思维过程失败或无法正确使用工具,可通过用户指导来缓解(例如\"尝试将任务分解为更小的步骤\")。", "violated_organization_allowlist": "执行任务失败:当前配置文件与您的组织设置不兼容", "condense_failed": "压缩上下文失败", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 92fdbaeb96..d78e73aab1 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -58,6 +58,7 @@ "cannot_access_path": "無法存取路徑 {{path}}:{{error}}", "path_outside_workspace": "路徑位於工作區之外", "settings_import_failed": "設定匯入失敗:{{error}}。", + "settings_save_failed": "無法儲存設定。部分設定可能仍處於部分變更的狀態:{{error}}", "mistake_limit_guidance": "這可能表明模型思維過程失敗或無法正確使用工具,可透過使用者指導來緩解(例如「嘗試將工作分解為更小的步驟」)。", "violated_organization_allowlist": "執行工作失敗:目前設定檔與您的組織設定不相容", "condense_failed": "壓縮上下文失敗", diff --git a/webview-ui/playwright/gallery/stories.tsx b/webview-ui/playwright/gallery/stories.tsx index 85d6919db4..aef15146a8 100644 --- a/webview-ui/playwright/gallery/stories.tsx +++ b/webview-ui/playwright/gallery/stories.tsx @@ -415,6 +415,11 @@ export const stories: Record = { ) }, + "language-settings": async () => { + const { LanguageSettingsStory } = + await import("@/components/settings/__tests__/LanguageSettings.visual.fixture") + return + }, "ui-settings": async () => { const { UISettingsStory } = await import("@/components/settings/__tests__/UISettings.visual.fixture") return diff --git a/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx b/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx index f5c2ba1062..052d58267e 100644 --- a/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx +++ b/webview-ui/src/__tests__/ContextWindowProgress.spec.tsx @@ -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()), formatLargeNumber: vi.fn((num) => num.toString()), })) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 77ae4529c0..99cb43094c 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -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" @@ -196,6 +197,8 @@ export const ChatRowContent = ({ clineMessages, currentTaskItem, enableCheckpoints, + timeFormat, + dateFormat, } = useExtensionState() const { info: model } = useSelectedModel(apiConfiguration) const [isEditing, setIsEditing] = useState(false) @@ -1224,6 +1227,13 @@ export const ChatRowContent = ({ )} + {/* When the reply was sent. Held back while it streams, so the line does not appear and + shift as the text grows. */} + {!message.partial && ( +
+ +
+ )} ) case "user_feedback": @@ -1299,6 +1309,12 @@ export const ChatRowContent = ({ )} + {/* When the message was sent. Hidden while editing: the edit box replaces the bubble. */} + {!isEditing && ( +
+ +
+ )} ) case "user_feedback_diff": @@ -1360,6 +1376,12 @@ export const ChatRowContent = ({ ) : null} + {/* When the final result was sent. Held back while it streams, like other replies. */} + {!message.partial && ( +
+ +
+ )} ) case "shell_integration_warning": diff --git a/webview-ui/src/components/chat/MessageTimestamp.tsx b/webview-ui/src/components/chat/MessageTimestamp.tsx new file mode 100644 index 0000000000..a925eb910c --- /dev/null +++ b/webview-ui/src/components/chat/MessageTimestamp.tsx @@ -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