diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a56a00fc355a..74d6ac27ebd9 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -148,6 +148,7 @@ export const globalSettingsSchema = z.object({ includeTaskHistoryInEnhance: z.boolean().optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), + chatMessageFontSize: z.string().optional(), profileThresholds: z.record(z.string(), z.number()).optional(), hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index af5f9925c353..bc953b0a4ed3 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1621,6 +1621,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("reasoningBlockCollapsed", message.bool ?? true) // No need to call postStateToWebview here as the UI already updated optimistically break + case "chatMessageFontSize": + await updateGlobalState("chatMessageFontSize", message.text ?? "default") + await provider.postStateToWebview() + break case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 66f389f81c10..55dd1ea45bd6 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -289,6 +289,7 @@ export type ExtensionState = Pick< | "includeTaskHistoryInEnhance" | "reasoningBlockCollapsed" > & { + chatMessageFontSize?: string version: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index d43a2fce0434..876523b93ce4 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -195,6 +195,7 @@ export interface WebviewMessage { | "profileThresholds" | "setHistoryPreviewCollapsed" | "setReasoningBlockCollapsed" + | "chatMessageFontSize" | "openExternal" | "filterMarketplaceItems" | "marketplaceButtonClicked" diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 26bc71074adb..07b734639930 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -77,6 +77,7 @@ interface ChatRowProps { onFollowUpUnmount?: () => void isFollowUpAnswered?: boolean editable?: boolean + chatMessageFontSize?: string } // eslint-disable-next-line @typescript-eslint/no-empty-object-type @@ -84,13 +85,29 @@ interface ChatRowContentProps extends Omit {} const ChatRow = memo( (props: ChatRowProps) => { - const { isLast, onHeightChange, message } = props + const { isLast, onHeightChange, message, chatMessageFontSize } = props // Store the previous height to compare with the current height // This allows us to detect changes without causing re-renders const prevHeightRef = useRef(0) + // Calculate the font size scale based on the setting + const fontSizeScale = useMemo(() => { + switch (chatMessageFontSize) { + case "extra-small": + return "90%" + case "small": + return "95%" + case "large": + return "105%" + case "extra-large": + return "110%" + default: + return "100%" + } + }, [chatMessageFontSize]) + const [chatrow, { height }] = useSize( -
+
, ) diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index d358c68f1cff..848dd893186d 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -123,6 +123,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction(({ onDone, t openRouterImageApiKey, openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, + chatMessageFontSize, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -384,6 +385,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} }) vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true }) vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: reasoningBlockCollapsed ?? true }) + vscode.postMessage({ type: "chatMessageFontSize", text: chatMessageFontSize || "default" }) vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "profileThresholds", values: profileThresholds }) @@ -782,6 +784,7 @@ const SettingsView = forwardRef(({ onDone, t {activeTab === "ui" && ( )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index 2de16e688224..58fe707b2f59 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -1,6 +1,6 @@ import { HTMLAttributes } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" -import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react" +import { VSCodeCheckbox, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react" import { Glasses } from "lucide-react" import { telemetryClient } from "@/utils/TelemetryClient" @@ -11,10 +11,16 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext" interface UISettingsProps extends HTMLAttributes { reasoningBlockCollapsed: boolean + chatMessageFontSize: string setCachedStateField: SetCachedStateField } -export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...props }: UISettingsProps) => { +export const UISettings = ({ + reasoningBlockCollapsed, + chatMessageFontSize, + setCachedStateField, + ...props +}: UISettingsProps) => { const { t } = useAppTranslation() const handleReasoningBlockCollapsedChange = (value: boolean) => { @@ -26,6 +32,15 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr }) } + const handleChatMessageFontSizeChange = (value: string) => { + setCachedStateField("chatMessageFontSize", value) + + // Track telemetry event + telemetryClient.capture("ui_settings_chat_font_size_changed", { + size: value, + }) + } + return (
@@ -49,6 +64,24 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr {t("settings:ui.collapseThinking.description")}
+ + {/* Chat Message Font Size Setting */} +
+ + handleChatMessageFontSizeChange(e.target.value)} + data-testid="chat-font-size-dropdown"> + Default + Extra Small (90%) + Small (95%) + Large (105%) + Extra Large (110%) + +
+ Adjust the font size for messages in the chat view +
+
diff --git a/webview-ui/src/components/settings/__tests__/UISettings.fontsize.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.fontsize.spec.tsx new file mode 100644 index 000000000000..d2504fa340ab --- /dev/null +++ b/webview-ui/src/components/settings/__tests__/UISettings.fontsize.spec.tsx @@ -0,0 +1,127 @@ +// npx vitest run src/components/settings/__tests__/UISettings.fontsize.spec.tsx + +import { render, screen } from "@testing-library/react" +import { describe, it, expect, vi, beforeEach } from "vitest" +import "@testing-library/jest-dom" +import { UISettings } from "../UISettings" +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import React from "react" + +// Mock vscode API +const mockPostMessage = vi.fn() +;(global as any).vscode = { + postMessage: mockPostMessage, +} + +// Mock the useTranslation hook +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), + Trans: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +// Mock VSCode webview UI toolkit components +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeDropdown: ({ children, value, onChange }: any) => ( + + ), + VSCodeOption: ({ children, value }: any) => ( + + ), + VSCodeCheckbox: ({ children }: any) => , +})) + +// Mock useExtensionState hook to provide consistent state +vi.mock("@src/context/ExtensionStateContext", async () => { + const actual = await vi.importActual("@src/context/ExtensionStateContext") + return { + ...actual, + useExtensionState: () => ({ + chatMessageFontSize: "default", + setChatMessageFontSize: vi.fn(), + soundEnabled: true, + setSoundEnabled: vi.fn(), + soundVolume: 0.5, + setSoundVolume: vi.fn(), + diffEnabled: true, + setDiffEnabled: vi.fn(), + browserActionType: "auto", + setBrowserActionType: vi.fn(), + }), + } +}) + +describe("UISettings - Chat Message Font Size", () => { + const defaultProps = { + expandedRows: {}, + setExpandedRows: vi.fn(), + isHidden: false, + hideAnnouncement: vi.fn(), + soundEnabled: true, + soundVolume: 0.5, + diffEnabled: true, + allowedCommands: [], + deniedCommands: [], + historyPreviewCollapsed: false, + chatMessageFontSize: "default", + reasoningBlockCollapsed: false, + setCachedStateField: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should render chat message font size dropdown with all options", () => { + render( + + + , + ) + + // Check that the dropdown is rendered + const dropdown = screen.getByRole("combobox") + expect(dropdown).toBeInTheDocument() + + // Check that all font size options are available + const options = screen.getAllByRole("option") + const optionTexts = options.map((opt) => opt.textContent) + + expect(optionTexts).toContain("Default") + expect(optionTexts).toContain("Extra Small (90%)") + expect(optionTexts).toContain("Small (95%)") + expect(optionTexts).toContain("Large (105%)") + expect(optionTexts).toContain("Extra Large (110%)") + }) + + it("should display the current font size setting", () => { + const props = { ...defaultProps, chatMessageFontSize: "large" } + render( + + + , + ) + + const dropdown = screen.getByRole("combobox") + expect(dropdown).toHaveValue("large") + }) + + it("should handle undefined chatMessageFontSize gracefully", () => { + // Use type assertion to test undefined case + const props = { ...defaultProps, chatMessageFontSize: undefined as any } + render( + + + , + ) + + const dropdown = screen.getByRole("combobox") + // Should default to "default" when undefined + expect(dropdown).toHaveValue("default") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 43bb013a08f2..9d9e014c5452 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -6,6 +6,7 @@ describe("UISettings", () => { const defaultProps = { reasoningBlockCollapsed: false, setCachedStateField: vi.fn(), + chatMessageFontSize: "default", } it("renders the collapse thinking checkbox", () => { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 542b2385c026..832047fe4034 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -158,6 +158,8 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxDiagnosticMessages: (value: number) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance: (value: boolean) => void + chatMessageFontSize?: string + setChatMessageFontSize: (value: string) => void } export const ExtensionStateContext = createContext(undefined) @@ -266,6 +268,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode maxDiagnosticMessages: 50, openRouterImageApiKey: "", openRouterImageGenerationSelectedModel: "", + chatMessageFontSize: "default", // Default font size }) const [didHydrateState, setDidHydrateState] = useState(false) @@ -285,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode global: {}, }) const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true) + const [chatMessageFontSize, setChatMessageFontSize] = useState("default") const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -322,6 +326,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode if ((newState as any).includeTaskHistoryInEnhance !== undefined) { setIncludeTaskHistoryInEnhance((newState as any).includeTaskHistoryInEnhance) } + // Update chatMessageFontSize if present in state message + if ((newState as any).chatMessageFontSize !== undefined) { + setChatMessageFontSize((newState as any).chatMessageFontSize) + } // Handle marketplace data if present in state message if (newState.marketplaceItems !== undefined) { setMarketplaceItems(newState.marketplaceItems) @@ -559,6 +567,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance, + chatMessageFontSize, + setChatMessageFontSize, } return {children}