import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, } from "react" import { CheckCheck, SquareMousePointer, Webhook, GitBranch, Bell, Database, SquareTerminal, FlaskConical, AlertTriangle, Globe, Info, MessageSquare, FileEdit, LucideIcon, } from "lucide-react" import type { ProviderSettings, ExperimentId } from "@roo-code/types" import { TelemetrySetting } from "@roo/TelemetrySetting" import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext" import { AlertDialog, AlertDialogContent, AlertDialogTitle, AlertDialogDescription, AlertDialogCancel, AlertDialogAction, AlertDialogHeader, AlertDialogFooter, Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, StandardTooltip, } from "@src/components/ui" import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab" import { SetCachedStateField, SetExperimentEnabled } from "./types" import { SectionHeader } from "./SectionHeader" import ApiConfigManager from "./ApiConfigManager" import ApiOptions from "./ApiOptions" import { AutoApproveSettings } from "./AutoApproveSettings" import { BrowserSettings } from "./BrowserSettings" import { CheckpointSettings } from "./CheckpointSettings" import { NotificationSettings } from "./NotificationSettings" import { ContextManagementSettings } from "./ContextManagementSettings" import { TerminalSettings } from "./TerminalSettings" import { ExperimentalSettings } from "./ExperimentalSettings" import { LanguageSettings } from "./LanguageSettings" import { About } from "./About" import { Section } from "./Section" import PromptsSettings from "./PromptsSettings" import { cn } from "@/lib/utils" import { FileEditingOptions } from "./FileEditingOptions" export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden" export const settingsTabList = "w-48 data-[compact=true]:w-12 flex-shrink-0 flex flex-col overflow-y-auto overflow-x-hidden border-r border-vscode-sideBar-background" export const settingsTabTrigger = "whitespace-nowrap overflow-hidden min-w-0 h-12 px-4 py-3 box-border flex items-center border-l-2 border-transparent text-vscode-foreground opacity-70 hover:bg-vscode-list-hoverBackground data-[compact=true]:w-12 data-[compact=true]:p-4" export const settingsTabTriggerActive = "opacity-100 border-vscode-focusBorder bg-vscode-list-activeSelectionBackground" export interface SettingsViewRef { checkUnsaveChanges: (then: () => void) => void } const sectionNames = [ "providers", "autoApprove", "browser", "checkpoints", "notifications", "contextManagement", "terminal", "fileEditing", "prompts", "experimental", "language", "about", ] as const type SectionName = (typeof sectionNames)[number] type SettingsViewProps = { onDone: () => void targetSection?: string } const SettingsView = forwardRef(({ onDone, targetSection }, ref) => { const { t } = useAppTranslation() const extensionState = useExtensionState() const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState const [isDiscardDialogShow, setDiscardDialogShow] = useState(false) const [isChangeDetected, setChangeDetected] = useState(false) const [errorMessage, setErrorMessage] = useState(undefined) const [activeTab, setActiveTab] = useState( targetSection && sectionNames.includes(targetSection as SectionName) ? (targetSection as SectionName) : "providers", ) const prevApiConfigName = useRef(currentApiConfigName) const confirmDialogHandler = useRef<() => void>() const [cachedState, setCachedState] = useState(extensionState) const { alwaysAllowReadOnly, alwaysAllowReadOnlyOutsideWorkspace, allowedCommands, deniedCommands, allowedMaxRequests, language, alwaysAllowBrowser, alwaysAllowExecute, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, alwaysApproveResubmit, autoCondenseContext, autoCondenseContextPercent, browserToolEnabled, browserViewportSize, enableCheckpoints, diffEnabled, experiments, fuzzyMatchThreshold, maxOpenTabsContext, maxWorkspaceFiles, mcpEnabled, requestDelaySeconds, remoteBrowserHost, screenshotQuality, soundEnabled, ttsEnabled, ttsSpeed, soundVolume, telemetrySetting, terminalOutputLineLimit, terminalOutputCharacterLimit, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, // Added from upstream terminalCommandDelay, terminalPowershellCounter, terminalZshClearEolMark, terminalZshOhMy, terminalZshP10k, terminalZdotdir, writeDelayMs, showRooIgnoredFiles, remoteBrowserEnabled, maxReadFileLine, terminalCompressProgressBar, maxConcurrentFileReads, condensingApiConfigId, customCondensingPrompt, customSupportPrompts, profileThresholds, alwaysAllowFollowupQuestions, alwaysAllowUpdateTodoList, followupAutoApproveTimeoutMs, autoCloseRooTabs, autoCloseAllRooTabs, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) useEffect(() => { // Update only when currentApiConfigName is changed. // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration. if (prevApiConfigName.current === currentApiConfigName) { return } setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) prevApiConfigName.current = currentApiConfigName setChangeDetected(false) }, [currentApiConfigName, extensionState, isChangeDetected]) // Bust the cache when settings are imported. useEffect(() => { if (settingsImportedAt) { setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState })) setChangeDetected(false) } }, [settingsImportedAt, extensionState]) const setCachedStateField: SetCachedStateField = useCallback((field, value) => { setCachedState((prevState) => { if (prevState[field] === value) { return prevState } setChangeDetected(true) return { ...prevState, [field]: value } }) }, []) const setApiConfigurationField = useCallback( (field: K, value: ProviderSettings[K]) => { setCachedState((prevState) => { if (prevState.apiConfiguration?.[field] === value) { return prevState } const previousValue = prevState.apiConfiguration?.[field] // Don't treat initial sync from undefined to a defined value as a user change // This prevents the dirty state when the component initializes and auto-syncs the model ID const isInitialSync = previousValue === undefined && value !== undefined if (!isInitialSync) { setChangeDetected(true) } return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } } }) }, [], ) const setExperimentEnabled: SetExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => { setCachedState((prevState) => { if (prevState.experiments?.[id] === enabled) { return prevState } setChangeDetected(true) return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } } }) }, []) const setTelemetrySetting = useCallback((setting: TelemetrySetting) => { setCachedState((prevState) => { if (prevState.telemetrySetting === setting) { return prevState } setChangeDetected(true) return { ...prevState, telemetrySetting: setting } }) }, []) const setCustomSupportPromptsField = useCallback((prompts: Record) => { setCachedState((prevState) => { if (JSON.stringify(prevState.customSupportPrompts) === JSON.stringify(prompts)) { return prevState } setChangeDetected(true) return { ...prevState, customSupportPrompts: prompts } }) }, []) const isSettingValid = !errorMessage const handleSubmit = () => { if (isSettingValid) { vscode.postMessage({ type: "language", text: language }) vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly }) vscode.postMessage({ type: "alwaysAllowReadOnlyOutsideWorkspace", bool: alwaysAllowReadOnlyOutsideWorkspace, }) vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite }) vscode.postMessage({ type: "alwaysAllowWriteOutsideWorkspace", bool: alwaysAllowWriteOutsideWorkspace }) vscode.postMessage({ type: "alwaysAllowWriteProtected", bool: alwaysAllowWriteProtected }) vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute }) vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser }) vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp }) vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] }) vscode.postMessage({ type: "deniedCommands", commands: deniedCommands ?? [] }) vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined }) vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext }) vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent }) vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled }) vscode.postMessage({ type: "soundEnabled", bool: soundEnabled }) vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled }) vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed }) vscode.postMessage({ type: "soundVolume", value: soundVolume }) vscode.postMessage({ type: "diffEnabled", bool: diffEnabled }) vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints }) vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize }) vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost }) vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled }) vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 }) vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs }) vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 }) vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 }) vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 }) vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout }) vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled }) vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay }) vscode.postMessage({ type: "terminalPowershellCounter", bool: terminalPowershellCounter }) vscode.postMessage({ type: "terminalZshClearEolMark", bool: terminalZshClearEolMark }) vscode.postMessage({ type: "terminalZshOhMy", bool: terminalZshOhMy }) vscode.postMessage({ type: "terminalZshP10k", bool: terminalZshP10k }) vscode.postMessage({ type: "terminalZdotdir", bool: terminalZdotdir }) vscode.postMessage({ type: "terminalCompressProgressBar", bool: terminalCompressProgressBar }) vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext }) vscode.postMessage({ type: "maxWorkspaceFiles", value: maxWorkspaceFiles ?? 200 }) vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles }) vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 }) vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 }) vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) vscode.postMessage({ type: "updateExperimental", values: experiments }) vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch }) vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks }) vscode.postMessage({ type: "alwaysAllowFollowupQuestions", bool: alwaysAllowFollowupQuestions }) vscode.postMessage({ type: "alwaysAllowUpdateTodoList", bool: alwaysAllowUpdateTodoList }) vscode.postMessage({ type: "followupAutoApproveTimeoutMs", value: followupAutoApproveTimeoutMs }) vscode.postMessage({ type: "condensingApiConfigId", text: condensingApiConfigId || "" }) vscode.postMessage({ type: "updateCondensingPrompt", text: customCondensingPrompt || "" }) vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} }) vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "profileThresholds", values: profileThresholds }) vscode.postMessage({ type: "autoCloseRooTabs", bool: autoCloseRooTabs }) vscode.postMessage({ type: "autoCloseAllRooTabs", bool: autoCloseAllRooTabs }) setChangeDetected(false) } } const checkUnsaveChanges = useCallback( (then: () => void) => { if (isChangeDetected) { confirmDialogHandler.current = then setDiscardDialogShow(true) } else { then() } }, [isChangeDetected], ) useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges]) const onConfirmDialogResult = useCallback( (confirm: boolean) => { if (confirm) { // Discard changes: Reset state and flag setCachedState(extensionState) // Revert to original state setChangeDetected(false) // Reset change flag confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch) } // If confirm is false (Cancel), do nothing, dialog closes automatically }, [extensionState], // Depend on extensionState to get the latest original state ) // Handle tab changes with unsaved changes check const handleTabChange = useCallback( (newTab: SectionName) => { // Directly switch tab without checking for unsaved changes setActiveTab(newTab) }, [], // No dependency on isChangeDetected needed anymore ) // Store direct DOM element refs for each tab const tabRefs = useRef>( Object.fromEntries(sectionNames.map((name) => [name, null])) as Record, ) // Track whether we're in compact mode const [isCompactMode, setIsCompactMode] = useState(false) const containerRef = useRef(null) // Setup resize observer to detect when we should switch to compact mode useEffect(() => { if (!containerRef.current) return const observer = new ResizeObserver((entries) => { for (const entry of entries) { // If container width is less than 500px, switch to compact mode setIsCompactMode(entry.contentRect.width < 500) } }) observer.observe(containerRef.current) return () => { observer?.disconnect() } }, []) const sections: { id: SectionName; icon: LucideIcon }[] = useMemo( () => [ { id: "providers", icon: Webhook }, { id: "autoApprove", icon: CheckCheck }, { id: "browser", icon: SquareMousePointer }, { id: "checkpoints", icon: GitBranch }, { id: "notifications", icon: Bell }, { id: "contextManagement", icon: Database }, { id: "terminal", icon: SquareTerminal }, { id: "fileEditing", icon: FileEdit }, { id: "prompts", icon: MessageSquare }, { id: "experimental", icon: FlaskConical }, { id: "language", icon: Globe }, { id: "about", icon: Info }, ], [], // No dependencies needed now ) // Update target section logic to set active tab useEffect(() => { if (targetSection && sectionNames.includes(targetSection as SectionName)) { setActiveTab(targetSection as SectionName) } }, [targetSection]) // Function to scroll the active tab into view for vertical layout const scrollToActiveTab = useCallback(() => { const activeTabElement = tabRefs.current[activeTab] if (activeTabElement) { activeTabElement.scrollIntoView({ behavior: "auto", block: "nearest", }) } }, [activeTab]) // Effect to scroll when the active tab changes useEffect(() => { scrollToActiveTab() }, [activeTab, scrollToActiveTab]) // Effect to scroll when the webview becomes visible useLayoutEffect(() => { const handleMessage = (event: MessageEvent) => { const message = event.data if (message.type === "action" && message.action === "didBecomeVisible") { scrollToActiveTab() } } window.addEventListener("message", handleMessage) return () => { window.removeEventListener("message", handleMessage) } }, [scrollToActiveTab]) return (

{t("settings:header.title")}

{/* Vertical tabs layout */}
{/* Tab sidebar */} handleTabChange(value as SectionName)} className={cn(settingsTabList)} data-compact={isCompactMode} data-testid="settings-tab-list"> {sections.map(({ id, icon: Icon }) => { const isSelected = id === activeTab const onSelect = () => handleTabChange(id) // Base TabTrigger component definition // We pass isSelected manually for styling, but onSelect is handled conditionally const triggerComponent = ( (tabRefs.current[id] = element)} value={id} isSelected={isSelected} // Pass manually for styling state className={cn( isSelected // Use manual isSelected for styling ? `${settingsTabTrigger} ${settingsTabTriggerActive}` : settingsTabTrigger, "focus:ring-0", // Remove the focus ring styling )} data-testid={`tab-${id}`} data-compact={isCompactMode}>
{t(`settings:sections.${id}`)}
) if (isCompactMode) { // Wrap in Tooltip and manually add onClick to the trigger return ( {/* Clone to avoid ref issues if triggerComponent itself had a key */} {React.cloneElement(triggerComponent)}

{t(`settings:sections.${id}`)}

) } else { // Render trigger directly; TabList will inject onSelect via cloning // Ensure the element passed to TabList has the key return React.cloneElement(triggerComponent, { key: id }) } })}
{/* Content area */} {/* Providers Section */} {activeTab === "providers" && (
{t("settings:sections.providers")}
checkUnsaveChanges(() => vscode.postMessage({ type: "loadApiConfiguration", text: configName }), ) } onDeleteConfig={(configName: string) => vscode.postMessage({ type: "deleteApiConfiguration", text: configName }) } onRenameConfig={(oldName: string, newName: string) => { vscode.postMessage({ type: "renameApiConfiguration", values: { oldName, newName }, apiConfiguration, }) prevApiConfigName.current = newName }} onUpsertConfig={(configName: string) => vscode.postMessage({ type: "upsertApiConfiguration", text: configName, apiConfiguration, }) } />
)} {/* Auto-Approve Section */} {activeTab === "autoApprove" && ( )} {/* Browser Section */} {activeTab === "browser" && ( )} {/* Checkpoints Section */} {activeTab === "checkpoints" && ( )} {/* Notifications Section */} {activeTab === "notifications" && ( )} {/* Context Management Section */} {activeTab === "contextManagement" && ( )} {/* Terminal Section */} {activeTab === "terminal" && ( )} {/* File Editing Section */} {activeTab === "fileEditing" && ( )} {/* Prompts Section */} {activeTab === "prompts" && ( )} {/* Experimental Section */} {activeTab === "experimental" && ( )} {/* Language Section */} {activeTab === "language" && ( )} {/* About Section */} {activeTab === "about" && ( )}
{t("settings:unsavedChangesDialog.title")} {t("settings:unsavedChangesDialog.description")} onConfirmDialogResult(false)}> {t("settings:unsavedChangesDialog.cancelButton")} onConfirmDialogResult(true)}> {t("settings:unsavedChangesDialog.discardButton")}
) }) export default memo(SettingsView)