SettingsView.tsx 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851
  1. import React, {
  2. forwardRef,
  3. memo,
  4. useCallback,
  5. useEffect,
  6. useImperativeHandle,
  7. useLayoutEffect,
  8. useMemo,
  9. useRef,
  10. useState,
  11. } from "react"
  12. import {
  13. CheckCheck,
  14. SquareMousePointer,
  15. Webhook,
  16. GitBranch,
  17. Bell,
  18. Database,
  19. SquareTerminal,
  20. FlaskConical,
  21. AlertTriangle,
  22. Globe,
  23. Info,
  24. MessageSquare,
  25. LucideIcon,
  26. SquareSlash,
  27. Glasses,
  28. } from "lucide-react"
  29. import type { ProviderSettings, ExperimentId, TelemetrySetting } from "@roo-code/types"
  30. import { vscode } from "@src/utils/vscode"
  31. import { cn } from "@src/lib/utils"
  32. import { useAppTranslation } from "@src/i18n/TranslationContext"
  33. import { ExtensionStateContextType, useExtensionState } from "@src/context/ExtensionStateContext"
  34. import {
  35. AlertDialog,
  36. AlertDialogContent,
  37. AlertDialogTitle,
  38. AlertDialogDescription,
  39. AlertDialogCancel,
  40. AlertDialogAction,
  41. AlertDialogHeader,
  42. AlertDialogFooter,
  43. Button,
  44. Tooltip,
  45. TooltipContent,
  46. TooltipProvider,
  47. TooltipTrigger,
  48. StandardTooltip,
  49. } from "@src/components/ui"
  50. import { Tab, TabContent, TabHeader, TabList, TabTrigger } from "../common/Tab"
  51. import { SetCachedStateField, SetExperimentEnabled } from "./types"
  52. import { SectionHeader } from "./SectionHeader"
  53. import ApiConfigManager from "./ApiConfigManager"
  54. import ApiOptions from "./ApiOptions"
  55. import { AutoApproveSettings } from "./AutoApproveSettings"
  56. import { BrowserSettings } from "./BrowserSettings"
  57. import { CheckpointSettings } from "./CheckpointSettings"
  58. import { NotificationSettings } from "./NotificationSettings"
  59. import { ContextManagementSettings } from "./ContextManagementSettings"
  60. import { TerminalSettings } from "./TerminalSettings"
  61. import { ExperimentalSettings } from "./ExperimentalSettings"
  62. import { LanguageSettings } from "./LanguageSettings"
  63. import { About } from "./About"
  64. import { Section } from "./Section"
  65. import PromptsSettings from "./PromptsSettings"
  66. import { SlashCommandsSettings } from "./SlashCommandsSettings"
  67. import { UISettings } from "./UISettings"
  68. export const settingsTabsContainer = "flex flex-1 overflow-hidden [&.narrow_.tab-label]:hidden"
  69. export const settingsTabList =
  70. "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"
  71. export const settingsTabTrigger =
  72. "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"
  73. export const settingsTabTriggerActive = "opacity-100 border-vscode-focusBorder bg-vscode-list-activeSelectionBackground"
  74. export interface SettingsViewRef {
  75. checkUnsaveChanges: (then: () => void) => void
  76. }
  77. const sectionNames = [
  78. "providers",
  79. "autoApprove",
  80. "slashCommands",
  81. "browser",
  82. "checkpoints",
  83. "notifications",
  84. "contextManagement",
  85. "terminal",
  86. "prompts",
  87. "ui",
  88. "experimental",
  89. "language",
  90. "about",
  91. ] as const
  92. type SectionName = (typeof sectionNames)[number]
  93. type SettingsViewProps = {
  94. onDone: () => void
  95. targetSection?: string
  96. }
  97. const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, targetSection }, ref) => {
  98. const { t } = useAppTranslation()
  99. const extensionState = useExtensionState()
  100. const { currentApiConfigName, listApiConfigMeta, uriScheme, settingsImportedAt } = extensionState
  101. const [isDiscardDialogShow, setDiscardDialogShow] = useState(false)
  102. const [isChangeDetected, setChangeDetected] = useState(false)
  103. const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined)
  104. const [activeTab, setActiveTab] = useState<SectionName>(
  105. targetSection && sectionNames.includes(targetSection as SectionName)
  106. ? (targetSection as SectionName)
  107. : "providers",
  108. )
  109. const scrollPositions = useRef<Record<SectionName, number>>(
  110. Object.fromEntries(sectionNames.map((s) => [s, 0])) as Record<SectionName, number>,
  111. )
  112. const contentRef = useRef<HTMLDivElement | null>(null)
  113. const prevApiConfigName = useRef(currentApiConfigName)
  114. const confirmDialogHandler = useRef<() => void>()
  115. const [cachedState, setCachedState] = useState(() => extensionState)
  116. const {
  117. alwaysAllowReadOnly,
  118. alwaysAllowReadOnlyOutsideWorkspace,
  119. allowedCommands,
  120. deniedCommands,
  121. allowedMaxRequests,
  122. allowedMaxCost,
  123. language,
  124. alwaysAllowBrowser,
  125. alwaysAllowExecute,
  126. alwaysAllowMcp,
  127. alwaysAllowModeSwitch,
  128. alwaysAllowSubtasks,
  129. alwaysAllowWrite,
  130. alwaysAllowWriteOutsideWorkspace,
  131. alwaysAllowWriteProtected,
  132. alwaysApproveResubmit,
  133. autoCondenseContext,
  134. autoCondenseContextPercent,
  135. browserToolEnabled,
  136. browserViewportSize,
  137. enableCheckpoints,
  138. checkpointTimeout,
  139. diffEnabled,
  140. experiments,
  141. fuzzyMatchThreshold,
  142. maxOpenTabsContext,
  143. maxWorkspaceFiles,
  144. mcpEnabled,
  145. requestDelaySeconds,
  146. remoteBrowserHost,
  147. screenshotQuality,
  148. soundEnabled,
  149. ttsEnabled,
  150. ttsSpeed,
  151. soundVolume,
  152. telemetrySetting,
  153. terminalOutputLineLimit,
  154. terminalOutputCharacterLimit,
  155. terminalShellIntegrationTimeout,
  156. terminalShellIntegrationDisabled, // Added from upstream
  157. terminalCommandDelay,
  158. terminalPowershellCounter,
  159. terminalZshClearEolMark,
  160. terminalZshOhMy,
  161. terminalZshP10k,
  162. terminalZdotdir,
  163. writeDelayMs,
  164. showRooIgnoredFiles,
  165. remoteBrowserEnabled,
  166. maxReadFileLine,
  167. maxImageFileSize,
  168. maxTotalImageSize,
  169. terminalCompressProgressBar,
  170. maxConcurrentFileReads,
  171. condensingApiConfigId,
  172. customCondensingPrompt,
  173. customSupportPrompts,
  174. profileThresholds,
  175. alwaysAllowFollowupQuestions,
  176. alwaysAllowUpdateTodoList,
  177. followupAutoApproveTimeoutMs,
  178. includeDiagnosticMessages,
  179. maxDiagnosticMessages,
  180. includeTaskHistoryInEnhance,
  181. openRouterImageApiKey,
  182. openRouterImageGenerationSelectedModel,
  183. reasoningBlockCollapsed,
  184. includeCurrentTime,
  185. includeCurrentCost,
  186. } = cachedState
  187. const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
  188. useEffect(() => {
  189. // Update only when currentApiConfigName is changed.
  190. // Expected to be triggered by loadApiConfiguration/upsertApiConfiguration.
  191. if (prevApiConfigName.current === currentApiConfigName) {
  192. return
  193. }
  194. setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
  195. prevApiConfigName.current = currentApiConfigName
  196. setChangeDetected(false)
  197. }, [currentApiConfigName, extensionState])
  198. // Bust the cache when settings are imported.
  199. useEffect(() => {
  200. if (settingsImportedAt) {
  201. setCachedState((prevCachedState) => ({ ...prevCachedState, ...extensionState }))
  202. setChangeDetected(false)
  203. }
  204. }, [settingsImportedAt, extensionState])
  205. const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
  206. setCachedState((prevState) => {
  207. if (prevState[field] === value) {
  208. return prevState
  209. }
  210. setChangeDetected(true)
  211. return { ...prevState, [field]: value }
  212. })
  213. }, [])
  214. const setApiConfigurationField = useCallback(
  215. <K extends keyof ProviderSettings>(field: K, value: ProviderSettings[K], isUserAction: boolean = true) => {
  216. setCachedState((prevState) => {
  217. if (prevState.apiConfiguration?.[field] === value) {
  218. return prevState
  219. }
  220. const previousValue = prevState.apiConfiguration?.[field]
  221. // Only skip change detection for automatic initialization (not user actions)
  222. // This prevents the dirty state when the component initializes and auto-syncs values
  223. // Treat undefined, null, and empty string as uninitialized states
  224. const isInitialSync =
  225. !isUserAction &&
  226. (previousValue === undefined || previousValue === "" || previousValue === null) &&
  227. value !== undefined &&
  228. value !== "" &&
  229. value !== null
  230. if (!isInitialSync) {
  231. setChangeDetected(true)
  232. }
  233. return { ...prevState, apiConfiguration: { ...prevState.apiConfiguration, [field]: value } }
  234. })
  235. },
  236. [],
  237. )
  238. const setExperimentEnabled: SetExperimentEnabled = useCallback((id: ExperimentId, enabled: boolean) => {
  239. setCachedState((prevState) => {
  240. if (prevState.experiments?.[id] === enabled) {
  241. return prevState
  242. }
  243. setChangeDetected(true)
  244. return { ...prevState, experiments: { ...prevState.experiments, [id]: enabled } }
  245. })
  246. }, [])
  247. const setTelemetrySetting = useCallback((setting: TelemetrySetting) => {
  248. setCachedState((prevState) => {
  249. if (prevState.telemetrySetting === setting) {
  250. return prevState
  251. }
  252. setChangeDetected(true)
  253. return { ...prevState, telemetrySetting: setting }
  254. })
  255. }, [])
  256. const setOpenRouterImageApiKey = useCallback((apiKey: string) => {
  257. setCachedState((prevState) => {
  258. // Only set change detected if value actually changed
  259. if (prevState.openRouterImageApiKey !== apiKey) {
  260. setChangeDetected(true)
  261. }
  262. return { ...prevState, openRouterImageApiKey: apiKey }
  263. })
  264. }, [])
  265. const setImageGenerationSelectedModel = useCallback((model: string) => {
  266. setCachedState((prevState) => {
  267. // Only set change detected if value actually changed
  268. if (prevState.openRouterImageGenerationSelectedModel !== model) {
  269. setChangeDetected(true)
  270. }
  271. return { ...prevState, openRouterImageGenerationSelectedModel: model }
  272. })
  273. }, [])
  274. const setCustomSupportPromptsField = useCallback((prompts: Record<string, string | undefined>) => {
  275. setCachedState((prevState) => {
  276. const previousStr = JSON.stringify(prevState.customSupportPrompts)
  277. const newStr = JSON.stringify(prompts)
  278. if (previousStr === newStr) {
  279. return prevState
  280. }
  281. setChangeDetected(true)
  282. return { ...prevState, customSupportPrompts: prompts }
  283. })
  284. }, [])
  285. const isSettingValid = !errorMessage
  286. const handleSubmit = () => {
  287. if (isSettingValid) {
  288. vscode.postMessage({ type: "language", text: language })
  289. vscode.postMessage({ type: "alwaysAllowReadOnly", bool: alwaysAllowReadOnly })
  290. vscode.postMessage({
  291. type: "alwaysAllowReadOnlyOutsideWorkspace",
  292. bool: alwaysAllowReadOnlyOutsideWorkspace,
  293. })
  294. vscode.postMessage({ type: "alwaysAllowWrite", bool: alwaysAllowWrite })
  295. vscode.postMessage({ type: "alwaysAllowWriteOutsideWorkspace", bool: alwaysAllowWriteOutsideWorkspace })
  296. vscode.postMessage({ type: "alwaysAllowWriteProtected", bool: alwaysAllowWriteProtected })
  297. vscode.postMessage({ type: "alwaysAllowExecute", bool: alwaysAllowExecute })
  298. vscode.postMessage({ type: "alwaysAllowBrowser", bool: alwaysAllowBrowser })
  299. vscode.postMessage({ type: "alwaysAllowMcp", bool: alwaysAllowMcp })
  300. vscode.postMessage({ type: "allowedCommands", commands: allowedCommands ?? [] })
  301. vscode.postMessage({ type: "deniedCommands", commands: deniedCommands ?? [] })
  302. vscode.postMessage({ type: "allowedMaxRequests", value: allowedMaxRequests ?? undefined })
  303. vscode.postMessage({ type: "allowedMaxCost", value: allowedMaxCost ?? undefined })
  304. vscode.postMessage({ type: "autoCondenseContext", bool: autoCondenseContext })
  305. vscode.postMessage({ type: "autoCondenseContextPercent", value: autoCondenseContextPercent })
  306. vscode.postMessage({ type: "browserToolEnabled", bool: browserToolEnabled })
  307. vscode.postMessage({ type: "soundEnabled", bool: soundEnabled })
  308. vscode.postMessage({ type: "ttsEnabled", bool: ttsEnabled })
  309. vscode.postMessage({ type: "ttsSpeed", value: ttsSpeed })
  310. vscode.postMessage({ type: "soundVolume", value: soundVolume })
  311. vscode.postMessage({ type: "diffEnabled", bool: diffEnabled })
  312. vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
  313. vscode.postMessage({ type: "checkpointTimeout", value: checkpointTimeout })
  314. vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
  315. vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
  316. vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
  317. vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
  318. vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
  319. vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
  320. vscode.postMessage({ type: "terminalOutputLineLimit", value: terminalOutputLineLimit ?? 500 })
  321. vscode.postMessage({ type: "terminalOutputCharacterLimit", value: terminalOutputCharacterLimit ?? 50000 })
  322. vscode.postMessage({ type: "terminalShellIntegrationTimeout", value: terminalShellIntegrationTimeout })
  323. vscode.postMessage({ type: "terminalShellIntegrationDisabled", bool: terminalShellIntegrationDisabled })
  324. vscode.postMessage({ type: "terminalCommandDelay", value: terminalCommandDelay })
  325. vscode.postMessage({ type: "terminalPowershellCounter", bool: terminalPowershellCounter })
  326. vscode.postMessage({ type: "terminalZshClearEolMark", bool: terminalZshClearEolMark })
  327. vscode.postMessage({ type: "terminalZshOhMy", bool: terminalZshOhMy })
  328. vscode.postMessage({ type: "terminalZshP10k", bool: terminalZshP10k })
  329. vscode.postMessage({ type: "terminalZdotdir", bool: terminalZdotdir })
  330. vscode.postMessage({ type: "terminalCompressProgressBar", bool: terminalCompressProgressBar })
  331. vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled })
  332. vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit })
  333. vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds })
  334. vscode.postMessage({ type: "maxOpenTabsContext", value: maxOpenTabsContext })
  335. vscode.postMessage({ type: "maxWorkspaceFiles", value: maxWorkspaceFiles ?? 200 })
  336. vscode.postMessage({ type: "showRooIgnoredFiles", bool: showRooIgnoredFiles })
  337. vscode.postMessage({ type: "maxReadFileLine", value: maxReadFileLine ?? -1 })
  338. vscode.postMessage({ type: "maxImageFileSize", value: maxImageFileSize ?? 5 })
  339. vscode.postMessage({ type: "maxTotalImageSize", value: maxTotalImageSize ?? 20 })
  340. vscode.postMessage({ type: "maxConcurrentFileReads", value: cachedState.maxConcurrentFileReads ?? 5 })
  341. vscode.postMessage({ type: "includeDiagnosticMessages", bool: includeDiagnosticMessages })
  342. vscode.postMessage({ type: "maxDiagnosticMessages", value: maxDiagnosticMessages ?? 50 })
  343. vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName })
  344. vscode.postMessage({ type: "updateExperimental", values: experiments })
  345. vscode.postMessage({ type: "alwaysAllowModeSwitch", bool: alwaysAllowModeSwitch })
  346. vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks })
  347. vscode.postMessage({ type: "alwaysAllowFollowupQuestions", bool: alwaysAllowFollowupQuestions })
  348. vscode.postMessage({ type: "alwaysAllowUpdateTodoList", bool: alwaysAllowUpdateTodoList })
  349. vscode.postMessage({ type: "followupAutoApproveTimeoutMs", value: followupAutoApproveTimeoutMs })
  350. vscode.postMessage({ type: "condensingApiConfigId", text: condensingApiConfigId || "" })
  351. vscode.postMessage({ type: "updateCondensingPrompt", text: customCondensingPrompt || "" })
  352. vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} })
  353. vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true })
  354. vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: reasoningBlockCollapsed ?? true })
  355. vscode.postMessage({ type: "includeCurrentTime", bool: includeCurrentTime ?? true })
  356. vscode.postMessage({ type: "includeCurrentCost", bool: includeCurrentCost ?? true })
  357. vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
  358. vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
  359. vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
  360. vscode.postMessage({ type: "openRouterImageApiKey", text: openRouterImageApiKey })
  361. vscode.postMessage({
  362. type: "openRouterImageGenerationSelectedModel",
  363. text: openRouterImageGenerationSelectedModel,
  364. })
  365. setChangeDetected(false)
  366. }
  367. }
  368. const checkUnsaveChanges = useCallback(
  369. (then: () => void) => {
  370. if (isChangeDetected) {
  371. confirmDialogHandler.current = then
  372. setDiscardDialogShow(true)
  373. } else {
  374. then()
  375. }
  376. },
  377. [isChangeDetected],
  378. )
  379. useImperativeHandle(ref, () => ({ checkUnsaveChanges }), [checkUnsaveChanges])
  380. const onConfirmDialogResult = useCallback(
  381. (confirm: boolean) => {
  382. if (confirm) {
  383. // Discard changes: Reset state and flag
  384. setCachedState(extensionState) // Revert to original state
  385. setChangeDetected(false) // Reset change flag
  386. confirmDialogHandler.current?.() // Execute the pending action (e.g., tab switch)
  387. }
  388. // If confirm is false (Cancel), do nothing, dialog closes automatically
  389. },
  390. [extensionState], // Depend on extensionState to get the latest original state
  391. )
  392. // Handle tab changes with unsaved changes check
  393. const handleTabChange = useCallback(
  394. (newTab: SectionName) => {
  395. if (contentRef.current) {
  396. scrollPositions.current[activeTab] = contentRef.current.scrollTop
  397. }
  398. setActiveTab(newTab)
  399. },
  400. [activeTab],
  401. )
  402. useLayoutEffect(() => {
  403. if (contentRef.current) {
  404. contentRef.current.scrollTop = scrollPositions.current[activeTab] ?? 0
  405. }
  406. }, [activeTab])
  407. // Store direct DOM element refs for each tab
  408. const tabRefs = useRef<Record<SectionName, HTMLButtonElement | null>>(
  409. Object.fromEntries(sectionNames.map((name) => [name, null])) as Record<SectionName, HTMLButtonElement | null>,
  410. )
  411. // Track whether we're in compact mode
  412. const [isCompactMode, setIsCompactMode] = useState(false)
  413. const containerRef = useRef<HTMLDivElement>(null)
  414. // Setup resize observer to detect when we should switch to compact mode
  415. useEffect(() => {
  416. if (!containerRef.current) return
  417. const observer = new ResizeObserver((entries) => {
  418. for (const entry of entries) {
  419. // If container width is less than 500px, switch to compact mode
  420. setIsCompactMode(entry.contentRect.width < 500)
  421. }
  422. })
  423. observer.observe(containerRef.current)
  424. return () => {
  425. observer?.disconnect()
  426. }
  427. }, [])
  428. const sections: { id: SectionName; icon: LucideIcon }[] = useMemo(
  429. () => [
  430. { id: "providers", icon: Webhook },
  431. { id: "autoApprove", icon: CheckCheck },
  432. { id: "slashCommands", icon: SquareSlash },
  433. { id: "browser", icon: SquareMousePointer },
  434. { id: "checkpoints", icon: GitBranch },
  435. { id: "notifications", icon: Bell },
  436. { id: "contextManagement", icon: Database },
  437. { id: "terminal", icon: SquareTerminal },
  438. { id: "prompts", icon: MessageSquare },
  439. { id: "ui", icon: Glasses },
  440. { id: "experimental", icon: FlaskConical },
  441. { id: "language", icon: Globe },
  442. { id: "about", icon: Info },
  443. ],
  444. [], // No dependencies needed now
  445. )
  446. // Update target section logic to set active tab
  447. useEffect(() => {
  448. if (targetSection && sectionNames.includes(targetSection as SectionName)) {
  449. setActiveTab(targetSection as SectionName)
  450. }
  451. }, [targetSection])
  452. // Function to scroll the active tab into view for vertical layout
  453. const scrollToActiveTab = useCallback(() => {
  454. const activeTabElement = tabRefs.current[activeTab]
  455. if (activeTabElement) {
  456. activeTabElement.scrollIntoView({
  457. behavior: "auto",
  458. block: "nearest",
  459. })
  460. }
  461. }, [activeTab])
  462. // Effect to scroll when the active tab changes
  463. useEffect(() => {
  464. scrollToActiveTab()
  465. }, [activeTab, scrollToActiveTab])
  466. // Effect to scroll when the webview becomes visible
  467. useLayoutEffect(() => {
  468. const handleMessage = (event: MessageEvent) => {
  469. const message = event.data
  470. if (message.type === "action" && message.action === "didBecomeVisible") {
  471. scrollToActiveTab()
  472. }
  473. }
  474. window.addEventListener("message", handleMessage)
  475. return () => {
  476. window.removeEventListener("message", handleMessage)
  477. }
  478. }, [scrollToActiveTab])
  479. return (
  480. <Tab>
  481. <TabHeader className="flex justify-between items-center gap-2">
  482. <div className="flex items-center gap-1">
  483. <h3 className="text-vscode-foreground m-0">{t("settings:header.title")}</h3>
  484. </div>
  485. <div className="flex gap-2">
  486. <StandardTooltip
  487. content={
  488. !isSettingValid
  489. ? errorMessage
  490. : isChangeDetected
  491. ? t("settings:header.saveButtonTooltip")
  492. : t("settings:header.nothingChangedTooltip")
  493. }>
  494. <Button
  495. variant={isSettingValid ? "default" : "secondary"}
  496. className={!isSettingValid ? "!border-vscode-errorForeground" : ""}
  497. onClick={handleSubmit}
  498. disabled={!isChangeDetected || !isSettingValid}
  499. data-testid="save-button">
  500. {t("settings:common.save")}
  501. </Button>
  502. </StandardTooltip>
  503. <StandardTooltip content={t("settings:header.doneButtonTooltip")}>
  504. <Button variant="secondary" onClick={() => checkUnsaveChanges(onDone)}>
  505. {t("settings:common.done")}
  506. </Button>
  507. </StandardTooltip>
  508. </div>
  509. </TabHeader>
  510. {/* Vertical tabs layout */}
  511. <div ref={containerRef} className={cn(settingsTabsContainer, isCompactMode && "narrow")}>
  512. {/* Tab sidebar */}
  513. <TabList
  514. value={activeTab}
  515. onValueChange={(value) => handleTabChange(value as SectionName)}
  516. className={cn(settingsTabList)}
  517. data-compact={isCompactMode}
  518. data-testid="settings-tab-list">
  519. {sections.map(({ id, icon: Icon }) => {
  520. const isSelected = id === activeTab
  521. const onSelect = () => handleTabChange(id)
  522. // Base TabTrigger component definition
  523. // We pass isSelected manually for styling, but onSelect is handled conditionally
  524. const triggerComponent = (
  525. <TabTrigger
  526. ref={(element) => (tabRefs.current[id] = element)}
  527. value={id}
  528. isSelected={isSelected} // Pass manually for styling state
  529. className={cn(
  530. isSelected // Use manual isSelected for styling
  531. ? `${settingsTabTrigger} ${settingsTabTriggerActive}`
  532. : settingsTabTrigger,
  533. "focus:ring-0", // Remove the focus ring styling
  534. )}
  535. data-testid={`tab-${id}`}
  536. data-compact={isCompactMode}>
  537. <div className={cn("flex items-center gap-2", isCompactMode && "justify-center")}>
  538. <Icon className="w-4 h-4" />
  539. <span className="tab-label">{t(`settings:sections.${id}`)}</span>
  540. </div>
  541. </TabTrigger>
  542. )
  543. if (isCompactMode) {
  544. // Wrap in Tooltip and manually add onClick to the trigger
  545. return (
  546. <TooltipProvider key={id} delayDuration={300}>
  547. <Tooltip>
  548. <TooltipTrigger asChild onClick={onSelect}>
  549. {/* Clone to avoid ref issues if triggerComponent itself had a key */}
  550. {React.cloneElement(triggerComponent)}
  551. </TooltipTrigger>
  552. <TooltipContent side="right" className="text-base">
  553. <p className="m-0">{t(`settings:sections.${id}`)}</p>
  554. </TooltipContent>
  555. </Tooltip>
  556. </TooltipProvider>
  557. )
  558. } else {
  559. // Render trigger directly; TabList will inject onSelect via cloning
  560. // Ensure the element passed to TabList has the key
  561. return React.cloneElement(triggerComponent, { key: id })
  562. }
  563. })}
  564. </TabList>
  565. {/* Content area */}
  566. <TabContent ref={contentRef} className="p-0 flex-1 overflow-auto">
  567. {/* Providers Section */}
  568. {activeTab === "providers" && (
  569. <div>
  570. <SectionHeader>
  571. <div className="flex items-center gap-2">
  572. <Webhook className="w-4" />
  573. <div>{t("settings:sections.providers")}</div>
  574. </div>
  575. </SectionHeader>
  576. <Section>
  577. <ApiConfigManager
  578. currentApiConfigName={currentApiConfigName}
  579. listApiConfigMeta={listApiConfigMeta}
  580. onSelectConfig={(configName: string) =>
  581. checkUnsaveChanges(() =>
  582. vscode.postMessage({ type: "loadApiConfiguration", text: configName }),
  583. )
  584. }
  585. onDeleteConfig={(configName: string) =>
  586. vscode.postMessage({ type: "deleteApiConfiguration", text: configName })
  587. }
  588. onRenameConfig={(oldName: string, newName: string) => {
  589. vscode.postMessage({
  590. type: "renameApiConfiguration",
  591. values: { oldName, newName },
  592. apiConfiguration,
  593. })
  594. prevApiConfigName.current = newName
  595. }}
  596. onUpsertConfig={(configName: string) =>
  597. vscode.postMessage({
  598. type: "upsertApiConfiguration",
  599. text: configName,
  600. apiConfiguration,
  601. })
  602. }
  603. />
  604. <ApiOptions
  605. uriScheme={uriScheme}
  606. apiConfiguration={apiConfiguration}
  607. setApiConfigurationField={setApiConfigurationField}
  608. errorMessage={errorMessage}
  609. setErrorMessage={setErrorMessage}
  610. />
  611. </Section>
  612. </div>
  613. )}
  614. {/* Auto-Approve Section */}
  615. {activeTab === "autoApprove" && (
  616. <AutoApproveSettings
  617. alwaysAllowReadOnly={alwaysAllowReadOnly}
  618. alwaysAllowReadOnlyOutsideWorkspace={alwaysAllowReadOnlyOutsideWorkspace}
  619. alwaysAllowWrite={alwaysAllowWrite}
  620. alwaysAllowWriteOutsideWorkspace={alwaysAllowWriteOutsideWorkspace}
  621. alwaysAllowWriteProtected={alwaysAllowWriteProtected}
  622. alwaysAllowBrowser={alwaysAllowBrowser}
  623. alwaysApproveResubmit={alwaysApproveResubmit}
  624. requestDelaySeconds={requestDelaySeconds}
  625. alwaysAllowMcp={alwaysAllowMcp}
  626. alwaysAllowModeSwitch={alwaysAllowModeSwitch}
  627. alwaysAllowSubtasks={alwaysAllowSubtasks}
  628. alwaysAllowExecute={alwaysAllowExecute}
  629. alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions}
  630. alwaysAllowUpdateTodoList={alwaysAllowUpdateTodoList}
  631. followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs}
  632. allowedCommands={allowedCommands}
  633. allowedMaxRequests={allowedMaxRequests ?? undefined}
  634. allowedMaxCost={allowedMaxCost ?? undefined}
  635. deniedCommands={deniedCommands}
  636. setCachedStateField={setCachedStateField}
  637. />
  638. )}
  639. {/* Slash Commands Section */}
  640. {activeTab === "slashCommands" && <SlashCommandsSettings />}
  641. {/* Browser Section */}
  642. {activeTab === "browser" && (
  643. <BrowserSettings
  644. browserToolEnabled={browserToolEnabled}
  645. browserViewportSize={browserViewportSize}
  646. screenshotQuality={screenshotQuality}
  647. remoteBrowserHost={remoteBrowserHost}
  648. remoteBrowserEnabled={remoteBrowserEnabled}
  649. setCachedStateField={setCachedStateField}
  650. />
  651. )}
  652. {/* Checkpoints Section */}
  653. {activeTab === "checkpoints" && (
  654. <CheckpointSettings
  655. enableCheckpoints={enableCheckpoints}
  656. checkpointTimeout={checkpointTimeout}
  657. setCachedStateField={setCachedStateField}
  658. />
  659. )}
  660. {/* Notifications Section */}
  661. {activeTab === "notifications" && (
  662. <NotificationSettings
  663. ttsEnabled={ttsEnabled}
  664. ttsSpeed={ttsSpeed}
  665. soundEnabled={soundEnabled}
  666. soundVolume={soundVolume}
  667. setCachedStateField={setCachedStateField}
  668. />
  669. )}
  670. {/* Context Management Section */}
  671. {activeTab === "contextManagement" && (
  672. <ContextManagementSettings
  673. autoCondenseContext={autoCondenseContext}
  674. autoCondenseContextPercent={autoCondenseContextPercent}
  675. listApiConfigMeta={listApiConfigMeta ?? []}
  676. maxOpenTabsContext={maxOpenTabsContext}
  677. maxWorkspaceFiles={maxWorkspaceFiles ?? 200}
  678. showRooIgnoredFiles={showRooIgnoredFiles}
  679. maxReadFileLine={maxReadFileLine}
  680. maxImageFileSize={maxImageFileSize}
  681. maxTotalImageSize={maxTotalImageSize}
  682. maxConcurrentFileReads={maxConcurrentFileReads}
  683. profileThresholds={profileThresholds}
  684. includeDiagnosticMessages={includeDiagnosticMessages}
  685. maxDiagnosticMessages={maxDiagnosticMessages}
  686. writeDelayMs={writeDelayMs}
  687. includeCurrentTime={includeCurrentTime}
  688. includeCurrentCost={includeCurrentCost}
  689. setCachedStateField={setCachedStateField}
  690. />
  691. )}
  692. {/* Terminal Section */}
  693. {activeTab === "terminal" && (
  694. <TerminalSettings
  695. terminalOutputLineLimit={terminalOutputLineLimit}
  696. terminalOutputCharacterLimit={terminalOutputCharacterLimit}
  697. terminalShellIntegrationTimeout={terminalShellIntegrationTimeout}
  698. terminalShellIntegrationDisabled={terminalShellIntegrationDisabled}
  699. terminalCommandDelay={terminalCommandDelay}
  700. terminalPowershellCounter={terminalPowershellCounter}
  701. terminalZshClearEolMark={terminalZshClearEolMark}
  702. terminalZshOhMy={terminalZshOhMy}
  703. terminalZshP10k={terminalZshP10k}
  704. terminalZdotdir={terminalZdotdir}
  705. terminalCompressProgressBar={terminalCompressProgressBar}
  706. setCachedStateField={setCachedStateField}
  707. />
  708. )}
  709. {/* Prompts Section */}
  710. {activeTab === "prompts" && (
  711. <PromptsSettings
  712. customSupportPrompts={customSupportPrompts || {}}
  713. setCustomSupportPrompts={setCustomSupportPromptsField}
  714. includeTaskHistoryInEnhance={includeTaskHistoryInEnhance}
  715. setIncludeTaskHistoryInEnhance={(value) =>
  716. setCachedStateField("includeTaskHistoryInEnhance", value)
  717. }
  718. />
  719. )}
  720. {/* UI Section */}
  721. {activeTab === "ui" && (
  722. <UISettings
  723. reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
  724. setCachedStateField={setCachedStateField}
  725. />
  726. )}
  727. {/* Experimental Section */}
  728. {activeTab === "experimental" && (
  729. <ExperimentalSettings
  730. setExperimentEnabled={setExperimentEnabled}
  731. experiments={experiments}
  732. apiConfiguration={apiConfiguration}
  733. setApiConfigurationField={setApiConfigurationField}
  734. openRouterImageApiKey={openRouterImageApiKey as string | undefined}
  735. openRouterImageGenerationSelectedModel={
  736. openRouterImageGenerationSelectedModel as string | undefined
  737. }
  738. setOpenRouterImageApiKey={setOpenRouterImageApiKey}
  739. setImageGenerationSelectedModel={setImageGenerationSelectedModel}
  740. />
  741. )}
  742. {/* Language Section */}
  743. {activeTab === "language" && (
  744. <LanguageSettings language={language || "en"} setCachedStateField={setCachedStateField} />
  745. )}
  746. {/* About Section */}
  747. {activeTab === "about" && (
  748. <About telemetrySetting={telemetrySetting} setTelemetrySetting={setTelemetrySetting} />
  749. )}
  750. </TabContent>
  751. </div>
  752. <AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
  753. <AlertDialogContent>
  754. <AlertDialogHeader>
  755. <AlertDialogTitle>
  756. <AlertTriangle className="w-5 h-5 text-yellow-500" />
  757. {t("settings:unsavedChangesDialog.title")}
  758. </AlertDialogTitle>
  759. <AlertDialogDescription>
  760. {t("settings:unsavedChangesDialog.description")}
  761. </AlertDialogDescription>
  762. </AlertDialogHeader>
  763. <AlertDialogFooter>
  764. <AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>
  765. {t("settings:unsavedChangesDialog.cancelButton")}
  766. </AlertDialogCancel>
  767. <AlertDialogAction onClick={() => onConfirmDialogResult(true)}>
  768. {t("settings:unsavedChangesDialog.discardButton")}
  769. </AlertDialogAction>
  770. </AlertDialogFooter>
  771. </AlertDialogContent>
  772. </AlertDialog>
  773. </Tab>
  774. )
  775. })
  776. export default memo(SettingsView)