feat: add chat message font size setting

- Add chatMessageFontSize property to ExtensionState and GlobalSettings
- Add UI dropdown in settings with 5 size options (Default, Extra Small, Small, Large, Extra Large)
- Apply font size scaling to ChatRow component (90%, 95%, 100%, 105%, 110%)
- Add message handler to persist the setting
- Include tests for the new functionality

Users can now control the font size for chat messages in ChatView/Virtuoso content
without affecting other UI elements
This commit is contained in:
Roo Code 2025-10-16 10:41:16 +00:00
parent 270dce5050
commit 6249077cde
10 changed files with 204 additions and 4 deletions

View file

@ -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(),

View file

@ -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") ?? {}

View file

@ -289,6 +289,7 @@ export type ExtensionState = Pick<
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
> & {
chatMessageFontSize?: string
version: string
clineMessages: ClineMessage[]
currentTaskItem?: HistoryItem

View file

@ -195,6 +195,7 @@ export interface WebviewMessage {
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "setReasoningBlockCollapsed"
| "chatMessageFontSize"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"

View file

@ -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<ChatRowProps, "onHeightChange"> {}
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(
<div className="px-[15px] py-[10px] pr-[6px]">
<div className="px-[15px] py-[10px] pr-[6px]" style={{ fontSize: fontSizeScale }}>
<ChatRowContent {...props} />
</div>,
)

View file

@ -123,6 +123,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
soundVolume,
cloudIsAuthenticated,
messageQueue = [],
chatMessageFontSize,
} = useExtensionState()
const messagesRef = useRef(messages)
@ -1541,6 +1542,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
onBatchFileResponse={handleBatchFileResponse}
onFollowUpUnmount={handleFollowUpUnmount}
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
chatMessageFontSize={chatMessageFontSize}
editable={
messageOrGroup.type === "ask" &&
messageOrGroup.ask === "tool" &&
@ -1576,6 +1578,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
alwaysAllowUpdateTodoList,
enableButtons,
primaryButtonText,
chatMessageFontSize,
],
)

View file

@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
chatMessageFontSize,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -384,6 +385,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone, t
{activeTab === "ui" && (
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
chatMessageFontSize={chatMessageFontSize || "default"}
setCachedStateField={setCachedStateField}
/>
)}

View file

@ -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<HTMLDivElement> {
reasoningBlockCollapsed: boolean
chatMessageFontSize: string
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}
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 (
<div {...props}>
<SectionHeader>
@ -49,6 +64,24 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
{t("settings:ui.collapseThinking.description")}
</div>
</div>
{/* Chat Message Font Size Setting */}
<div className="flex flex-col gap-1">
<label className="font-medium">Chat Message Font Size</label>
<VSCodeDropdown
value={chatMessageFontSize || "default"}
onChange={(e: any) => handleChatMessageFontSizeChange(e.target.value)}
data-testid="chat-font-size-dropdown">
<VSCodeOption value="default">Default</VSCodeOption>
<VSCodeOption value="extra-small">Extra Small (90%)</VSCodeOption>
<VSCodeOption value="small">Small (95%)</VSCodeOption>
<VSCodeOption value="large">Large (105%)</VSCodeOption>
<VSCodeOption value="extra-large">Extra Large (110%)</VSCodeOption>
</VSCodeDropdown>
<div className="text-vscode-descriptionForeground text-sm mt-1">
Adjust the font size for messages in the chat view
</div>
</div>
</div>
</Section>
</div>

View file

@ -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) => (
<select role="combobox" value={value} onChange={(e) => onChange && onChange(e)}>
{children}
</select>
),
VSCodeOption: ({ children, value }: any) => (
<option role="option" value={value}>
{children}
</option>
),
VSCodeCheckbox: ({ children }: any) => <label>{children}</label>,
}))
// 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(
<ExtensionStateContextProvider>
<UISettings {...defaultProps} />
</ExtensionStateContextProvider>,
)
// 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(
<ExtensionStateContextProvider>
<UISettings {...props} />
</ExtensionStateContextProvider>,
)
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(
<ExtensionStateContextProvider>
<UISettings {...props} />
</ExtensionStateContextProvider>,
)
const dropdown = screen.getByRole("combobox")
// Should default to "default" when undefined
expect(dropdown).toHaveValue("default")
})
})

View file

@ -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<ExtensionStateContextType | undefined>(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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>